blakegearin / Filterboxd

// ==UserScript==
// @name         Filterboxd
// @namespace    https://github.com/blakegearin/filterboxd
// @version      1.3.1
// @description  Filter content on Letterboxd
// @author       Blake Gearin <hello@blakeg.me> (https://blakegearin.com)
// @match        https://letterboxd.com/*
// @require      https://openuserjs.org/src/libs/sizzle/GM_config.js
// @grant        GM.getValue
// @grant        GM.setValue
// @icon         https://raw.githubusercontent.com/blakegearin/filterboxd/main/img/logo.svg
// @supportURL   https://github.com/blakegearin/filterboxd/issues
// @license      MIT
// @copyright    2024, Blake Gearin (https://blakegearin.com)
// ==/UserScript==

/* jshint esversion: 6 */
/* global GM_config */

(function() {
  'use strict';

  const RESET = false;

  const SILENT = 0;
  const QUIET = 1;
  const INFO = 2;
  const DEBUG = 3;
  const VERBOSE = 4;
  const TRACE = 5;

  let CURRENT_LOG_LEVEL = INFO;

  const USERSCRIPT_NAME = 'Filterboxd';

  function log(level, message, variable = -1) {
    if (CURRENT_LOG_LEVEL < level) return;

    console.log(`${USERSCRIPT_NAME}: ${message}`);
    if (variable !== -1) console.log(variable);
  }

  function logError(message, variable = null) {
    console.error(`${USERSCRIPT_NAME}: ${message}`);
    if (variable) console.log(variable);
  }

  log(TRACE, 'Starting');

  function updateLogLevel() {
    CURRENT_LOG_LEVEL = {
      silent: SILENT,
      quiet: QUIET,
      info: INFO,
      debug: DEBUG,
      verbose: VERBOSE,
      trace: TRACE,
    }[GMC.get('logLevel')];
  }

  function startObserving() {
    log(DEBUG, 'startObserving()');

    OBSERVER.observe(
      document.body,
      {
        childList: true,
        subtree: true,
      },
    );
  }

  function modifyThenObserve(callback) {
    log(DEBUG, 'modifyThenObserve()');

    OBSERVER.disconnect();
    callback();
    startObserving();
  }

  function mutationsExceedsLimits() {
    if (IDLE_MUTATION_COUNT > MAX_IDLE_MUTATIONS) {
      // This is a failsafe to prevent infinite loops
      logError('MAX_IDLE_MUTATIONS exceeded');
      OBSERVER.disconnect();

      return true;
    } else if (ACTIVE_MUTATION_COUNT >= MAX_ACTIVE_MUTATIONS) {
      // This is a failsafe to prevent infinite loops
      logError('MAX_ACTIVE_MUTATIONS exceeded');
      OBSERVER.disconnect();

      return true;
    }

    return false;
  }

  function observeAndModify(mutationsList) {
    log(VERBOSE, 'observeAndModify()');

    if (mutationsExceedsLimits()) return;

    log(VERBOSE, 'mutationsList.length', mutationsList.length);

    for (const mutation of mutationsList) {
      if (mutation.type !== 'childList') return;

      log(TRACE, 'mutation', mutation);

      let sidebarUpdated;
      let popMenuUpdated;
      let filtersApplied;

      modifyThenObserve(() => {
        sidebarUpdated = maybeAddListItemToSidebar();
        log(VERBOSE, 'sidebarUpdated', sidebarUpdated);

        popMenuUpdated = addListItemToPopMenu();
        log(VERBOSE, 'popMenuUpdated', popMenuUpdated);

        filtersApplied = applyFilters();
        log(VERBOSE, 'filtersApplied', filtersApplied);
      });

      const activeMutation = sidebarUpdated || popMenuUpdated || filtersApplied;
      log(DEBUG, 'activeMutation', activeMutation);

      if (activeMutation) {
        ACTIVE_MUTATION_COUNT++;
        log(VERBOSE, 'ACTIVE_MUTATION_COUNT', ACTIVE_MUTATION_COUNT);
      } else {
        IDLE_MUTATION_COUNT++;
        log(VERBOSE, 'IDLE_MUTATION_COUNT', IDLE_MUTATION_COUNT);
      }

      if (mutationsExceedsLimits()) break;
    }
  }

  function createId(string) {
    log(TRACE, 'createId()');

    if (string.startsWith('#')) return string;

    if (string.startsWith('.')) {
      logError(`Attempted to create an id from a class: "${string}"`);
      return;
    }

    if (string.startsWith('[')) {
      logError(`Attempted to create an id from an attribute selector: "${string}"`);
      return;
    }

    return `#${string}`;
  }

  const MAX_IDLE_MUTATIONS = 1000;
  const MAX_ACTIVE_MUTATIONS = 10000;
  const FILM_BEHAVIORS = [
    'Remove',
    'Fade',
    'Blur',
    'Replace poster',
    'Custom',
  ];
  const REVIEW_BEHAVIORS = [
    'Remove',
    'Fade',
    'Blur',
    'Replace text',
    'Custom',
  ];

  let IDLE_MUTATION_COUNT = 0;
  let ACTIVE_MUTATION_COUNT = 0;
  let SELECTORS = {
    filmPosterPopMenu: {
      self: '.film-poster-popmenu',
      userscriptListItemClass: 'filterboxd-list-item',
      addToList: '.film-poster-popmenu .menu-item-add-to-list',
      addThisFilm: '.film-poster-popmenu .menu-item-add-this-film',
    },
    filmPageSections: {
      backdropImage: 'body.backdrop-loaded .backdrop-container',
      // Left column
      poster: '#film-page-wrapper section.poster-list a[data-js-trigger="postermodal"]',
      stats: '#film-page-wrapper section.poster-list ul.film-stats',
      whereToWatch: '#film-page-wrapper section.watch-panel',
      // Right column
      userActionsPanel: '#film-page-wrapper section#userpanel',
      ratings: '#film-page-wrapper section.ratings-histogram-chart',
      // Middle column
      releaseYear: '#film-page-wrapper .details .releaseyear',
      director: '#film-page-wrapper .details .credits',
      tagline: '#film-page-wrapper .tagline',
      description: '#film-page-wrapper .truncate',
      castTab: '#film-page-wrapper #tabbed-content ul li:nth-of-type(1),#film-page-wrapper #tab-cast',
      crewTab: '#film-page-wrapper #tabbed-content ul li:nth-of-type(2),#film-page-wrapper #tab-crew',
      detailsTab: '#film-page-wrapper #tabbed-content ul li:nth-of-type(3),#film-page-wrapper #tab-details',
      genresTab: '#film-page-wrapper #tabbed-content ul li:nth-of-type(4),#film-page-wrapper #tab-genres',
      releasesTab: '#film-page-wrapper #tabbed-content ul li:nth-of-type(5),#film-page-wrapper #tab-releases',
      activityFromFriends: '#film-page-wrapper section.activity-from-friends',
      filmNews: '#film-page-wrapper section.film-news',
      reviewsFromFriends: '#film-page-wrapper section#popular-reviews-with-friends',
      popularReviews: '#film-page-wrapper section#popular-reviews',
      recentReviews: '#film-page-wrapper section#recent-reviews',
      relatedFilms: '#film-page-wrapper section#related',
      similarFilms: '#film-page-wrapper section.related-films:not(#related)',
      mentionedBy: '#film-page-wrapper section#film-hq-mentions',
      popularLists: '#film-page-wrapper section:has(#film-popular-lists)',
    },
    filter: {
      filmClass: 'filterboxd-filter-film',
      reviewClass: 'filterboxd-filter-review',
      reviews: {
        ratings: '.film-detail .attribution .rating,.film-detail-meta .rating,.activity-summary .rating,.film-metadata .rating,.-rated .rating,.poster-viewingdata .rating',
        likes: '.film-detail .attribution .icon-liked,.film-metadata .icon-liked,.review .like-link-target,.film-detail-content .like-link-target',
        comments: '.film-detail .attribution .content-metadata,#content #comments',
        withSpoilers: '.film-detail:has(.contains-spoilers)',
        withoutRatings: '.film-detail:not(:has(.rating))',
      },
    },
    homepageSections: {
      friendsHaveBeenWatching: '.person-home h1.title-hero span',
      newFromFriends: '.person-home section#recent-from-friends',
      popularWithFriends: '.person-home section#popular-with-friends',
      discoveryStream: '.person-home section.section-discovery-stream',
      latestNews: '.person-home section#latest-news:not(:has(.teaser-grid))',
      popularReviewsWithFriends: '.person-home section#popular-reviews',
      newListsFromFriends: '.person-home section:has([href="/lists/friends/"])',
      popularLists: '.person-home section:has([href="/lists/popular/this/week/"])',
      recentStories: '.person-home section.stories-section',
      recentShowdowns: '.person-home section:has([href="/showdown/"])',
      recentNews: '.person-home section#latest-news:has(.teaser-grid)',
    },
    processedClass: {
      apply: 'filterboxd-hide-processed',
      remove: 'filterboxd-unhide-processed',
    },
    settings: {
      clear: '.clear',
      favoriteFilms: '.favourite-films-selector',
      filteredTitleLinkClass: 'filtered-title-span',
      note: '.note',
      posterList: '.poster-list',
      removePendingClass: 'remove-pending',
      savedBadgeClass: 'filtered-saved',
      subNav: '.sub-nav',
      subtitle: '.mob-subtitle',
      tabbedContentId: '#tabbed-content',
    },
    userpanel: {
      self: '#userpanel',
      userscriptListItemId: 'filterboxd-list-item',
      addThisFilm: '#userpanel .add-this-film',
    },
  };

  function addListItemToPopMenu() {
    log(DEBUG, 'addListItemToPopMenu()');

    const filmPosterPopMenus = document.querySelectorAll(SELECTORS.filmPosterPopMenu.self);

    if (!filmPosterPopMenus) {
      log(`Selector ${SELECTORS.filmPosterPopMenu.self} not found`, DEBUG);
      return false;
    }

    let pageUpdated = false;

    filmPosterPopMenus.forEach(filmPosterPopMenu => {
      const userscriptListItemPresent = filmPosterPopMenu.querySelector(
        `.${SELECTORS.filmPosterPopMenu.userscriptListItemClass}`,
      );
      if (userscriptListItemPresent) return;

      const lastListItem = filmPosterPopMenu.querySelector('li:last-of-type');

      if (!lastListItem) {
        logError(`Selector ${SELECTORS.filmPosterPopMenu} li:last-of-type not found`);
        return;
      }

      const addToListLink = filmPosterPopMenu.querySelector(SELECTORS.filmPosterPopMenu.addToList);
      if (!addToListLink) {
        logError(`Selector ${SELECTORS.filmPosterPopMenu.addToList} not found`);
        return;
      }

      const addThisFilmLink = filmPosterPopMenu.querySelector(SELECTORS.filmPosterPopMenu.addThisFilm);
      if (!addThisFilmLink) {
        logError(`Selector ${SELECTORS.filmPosterPopMenu.addThisFilm} not found`);
        return;
      }

      modifyThenObserve(() => {
        let userscriptListItem = lastListItem.cloneNode(true);
        userscriptListItem.classList.add(SELECTORS.filmPosterPopMenu.userscriptListItemClass);

        userscriptListItem = buildUserscriptLink(userscriptListItem, addToListLink, addThisFilmLink);
        lastListItem.parentNode.append(userscriptListItem);
      });

      pageUpdated = true;
    });

    return pageUpdated;
  }

  function addFilterToFilm({ id, slug }) {
    log(DEBUG, 'addFilterToFilm()');

    let pageUpdated = false;

    const idMatch = `[data-film-id="${id}"]`;
    let appliedSelector = `.${SELECTORS.processedClass.apply}`;

    const replaceBehavior = GMC.get('filmBehaviorType') === 'Replace poster';
    log(VERBOSE, 'replaceBehavior', replaceBehavior);

    if (replaceBehavior) appliedSelector = '[data-original-img-src]';

    // Activity page reviews
    document.querySelectorAll(`section.activity-row ${idMatch}`).forEach(posterElement => {
      applyFilterToFilm(posterElement, 3);

      pageUpdated = true;
    });

    // Activity page likes
    document.querySelectorAll(`section.activity-row .activity-summary a[href*="${slug}"]:not(${appliedSelector})`).forEach(posterElement => {
      applyFilterToFilm(posterElement, 3);

      pageUpdated = true;
    });

    // New from friends
    document.querySelectorAll(`.poster-container ${idMatch}:not(${appliedSelector})`).forEach(posterElement => {
      applyFilterToFilm(posterElement, 1);

      pageUpdated = true;
    });

    // Reviews
    document.querySelectorAll(`.review-tile ${idMatch}:not(${appliedSelector})`).forEach(posterElement => {
      applyFilterToFilm(posterElement, 3);

      pageUpdated = true;
    });

    // Diary
    document.querySelectorAll(`.td-film-details [data-original-img-src]${idMatch}:not(${appliedSelector})`).forEach(posterElement => {
      applyFilterToFilm(posterElement, 2);

      pageUpdated = true;
    });

    // Popular with friends, competitions
    const remainingElements = document.querySelectorAll(
      `div:not(.popmenu):not(.actions-panel) ${idMatch}:not(aside [data-film-id="${id}"]):not(${appliedSelector})`,
    );
    remainingElements.forEach(posterElement => {
      applyFilterToFilm(posterElement, 0);

      pageUpdated = true;
    });

    return pageUpdated;
  }

  function addToHiddenTitles(filmMetadata) {
    log(DEBUG, 'addToHiddenTitles()');

    const filmFilter = getFilter('filmFilter');
    filmFilter.push(filmMetadata);
    log(VERBOSE, 'filmFilter', filmFilter);

    setFilter('filmFilter', filmFilter);
  }

  function applyFilters() {
    log(DEBUG, 'applyFilters()');

    let pageUpdated = false;

    const filmFilter = getFilter('filmFilter');
    log(VERBOSE, 'filmFilter', filmFilter);

    const reviewFilter = getFilter('reviewFilter');
    log(VERBOSE, 'reviewFilter', reviewFilter);

    const replaceBehavior = GMC.get('reviewBehaviorType') === 'Replace text';
    log(VERBOSE, 'replaceBehavior', replaceBehavior);

    const reviewBehaviorReplaceValue = GMC.get('reviewBehaviorReplaceValue');
    log(VERBOSE, 'reviewBehaviorReplaceValue', reviewBehaviorReplaceValue);

    const homepageFilter = getFilter('homepageFilter');
    log(VERBOSE, 'homepageFilter', homepageFilter);

    const filmPageFilter = getFilter('filmPageFilter');
    log(VERBOSE, 'filmPageFilter', filmPageFilter);

    modifyThenObserve(() => {
      filmFilter.forEach(filmMetadata => {
        const filmUpdated = addFilterToFilm(filmMetadata);
        if (filmUpdated) pageUpdated = true;
      });

      const selectorReviewElementsToFilter = [];
      if (reviewFilter.ratings) selectorReviewElementsToFilter.push(SELECTORS.filter.reviews.ratings);
      if (reviewFilter.likes) selectorReviewElementsToFilter.push(SELECTORS.filter.reviews.likes);
      if (reviewFilter.comments) selectorReviewElementsToFilter.push(SELECTORS.filter.reviews.comments);

      if (selectorReviewElementsToFilter.length) {
        document.querySelectorAll(selectorReviewElementsToFilter.join(',')).forEach(reviewElement => {
          reviewElement.style.display = 'none';

          pageUpdated = true;
        });
      }

      const reviewsToFilterSelectors = [];
      if (reviewFilter.withSpoilers) reviewsToFilterSelectors.push(SELECTORS.filter.reviews.withSpoilers);
      if (reviewFilter.withoutRatings) reviewsToFilterSelectors.push(SELECTORS.filter.reviews.withoutRatings);

      if (reviewsToFilterSelectors.length) {
        document.querySelectorAll(reviewsToFilterSelectors.join(',')).forEach(filteredTitleLink => {
          if (replaceBehavior) {
            filteredTitleLink.querySelector('.body-text').innerText = reviewBehaviorReplaceValue;
          } else {
            filteredTitleLink.classList.add(SELECTORS.filter.reviewClass);
          }

          pageUpdated = true;
        });
      }

      const sectionsToFilter = [];

      const homepageSectionsToFilter = Object.keys(homepageFilter)
        .filter(key => homepageFilter[key])
        .map(key => SELECTORS.homepageSections[key])
        .filter(Boolean);
      log(VERBOSE, 'homepageSectionToFilter', homepageSectionsToFilter);

      const filmPageSectionsToFilter = Object.keys(filmPageFilter)
        .filter(key => filmPageFilter[key])
        .map(key => SELECTORS.filmPageSections[key])
        .filter(Boolean);
      log(VERBOSE, 'filmPageSectionsToFilter', filmPageSectionsToFilter);

      sectionsToFilter.push(...homepageSectionsToFilter);
      sectionsToFilter.push(...filmPageSectionsToFilter);

      if (sectionsToFilter.length) {
        document.querySelectorAll(sectionsToFilter.join(',')).forEach(filterSection => {
          filterSection.style.display = 'none';

          pageUpdated = true;
        });
      }

      if (filmPageFilter.backdropImage) {
        document.querySelector('#content.-backdrop')?.classList.remove('-backdrop');
        pageUpdated = true;
      }
    });

    return pageUpdated;
  }

  function applyFilterToFilm(element, levelsUp = 0) {
    log(DEBUG, 'applyFilterToFilm()');

    const replaceBehavior = GMC.get('filmBehaviorType') === 'Replace poster';
    log(VERBOSE, 'replaceBehavior', replaceBehavior);

    if (replaceBehavior) {
      const filmBehaviorReplaceValue = GMC.get('filmBehaviorReplaceValue');
      log(VERBOSE, 'filmBehaviorReplaceValue', filmBehaviorReplaceValue);

      const elementImg = element.querySelector('img');
      if (!elementImg) return;

      const originalImgSrc = elementImg.src;
      if (!originalImgSrc) return;

      if (originalImgSrc === filmBehaviorReplaceValue) return;

      element.setAttribute('data-original-img-src', originalImgSrc);

      element.querySelector('img').src = filmBehaviorReplaceValue;
      element.querySelector('img').srcset = filmBehaviorReplaceValue;

      element.classList.add(SELECTORS.processedClass.apply);
    } else {
      let target = element;

      for (let i = 0; i < levelsUp; i++) {
        if (target.parentNode) {
          target = target.parentNode;
        } else {
          break;
        }
      }

      log(VERBOSE, 'target', target);

      target.classList.add(SELECTORS.filter.filmClass);
      element.classList.add(SELECTORS.processedClass.apply);
    }
  }

  function buildBehaviorFormRows(parentDiv, filterName, selectArrayValues, behaviorsMetadata) {
    const behaviorValue = GMC.get(`${filterName}BehaviorType`);
    log(DEBUG, 'behaviorValue', behaviorValue);

    const behaviorChange = (event) => {
      const filmBehaviorType = event.target.value;
      updateBehaviorCSSVariables(filterName, filmBehaviorType);
    };

    const columnOneWidth = '33%';
    const columnTwoWidth = '64.8%';

    const behaviorFormRow = createFormRow({
      formRowStyle: `width: ${columnOneWidth};`,
      labelText: 'Behavior',
      inputValue: behaviorValue,
      inputType: 'select',
      selectArray: selectArrayValues,
      selectOnChange: behaviorChange,
    });

    parentDiv.appendChild(behaviorFormRow);

    // Fade amount
    const behaviorFadeAmount = parseInt(GMC.get(behaviorsMetadata.fade.fieldName));
    log(DEBUG, 'behaviorFadeAmount', behaviorFadeAmount);

    const fadeAmountFormRow = createFormRow({
      formRowClass: ['update-details'],
      formRowStyle: `width: ${columnTwoWidth}; float: right; display: var(--filterboxd-${filterName}-behavior-fade);`,
      labelText: 'Amount',
      inputValue: behaviorFadeAmount,
      inputType: 'select',
      inputStyle: 'width: 100px !important;',
      selectArray: [ 0, 5, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90],
      notes: '%',
      notesStyle: 'width: 10px; margin-left: 14px;',
    });

    parentDiv.appendChild(fadeAmountFormRow);

    // Blur amount
    const behaviorBlurAmount = parseInt(GMC.get(behaviorsMetadata.blur.fieldName));
    log(DEBUG, 'behaviorBlurAmount', behaviorBlurAmount);

    const blurAmountFormRow = createFormRow({
      formRowClass: ['update-details'],
      formRowStyle: `width: ${columnTwoWidth}; float: right; display: var(--filterboxd-${filterName}-behavior-blur);`,
      labelText: 'Amount',
      inputValue: behaviorBlurAmount,
      inputType: 'select',
      inputStyle: 'width: 100px !important;',
      selectArray: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 1000 ],
      notes: 'px',
      notesStyle: 'width: 10px; margin-left: 14px;',
    });

    parentDiv.appendChild(blurAmountFormRow);

    // Replace value
    const behaviorReplaceValue = GMC.get(behaviorsMetadata.replace.fieldName);
    log(DEBUG, 'behaviorReplaceValue', behaviorReplaceValue);

    const replaceValueFormRow = createFormRow({
      formRowStyle: `width: ${columnTwoWidth}; float: right; display: var(--filterboxd-${filterName}-behavior-replace);`,
      labelText: behaviorsMetadata.replace.labelText,
      inputValue: behaviorReplaceValue,
      inputType: 'text',
    });

    parentDiv.appendChild(replaceValueFormRow);

    // Custom CSS
    const behaviorCustomValue = GMC.get(behaviorsMetadata.custom.fieldName);
    log(DEBUG, 'behaviorCustomValue', behaviorCustomValue);

    const cssFormRow = createFormRow({
      formRowStyle: `width: ${columnTwoWidth}; float: right; display: var(--filterboxd-${filterName}-behavior-custom);`,
      labelText: 'CSS',
      inputValue: behaviorCustomValue,
      inputType: 'text',
    });

    parentDiv.appendChild(cssFormRow);

    return [
      behaviorFormRow,
      fadeAmountFormRow,
      blurAmountFormRow,
      replaceValueFormRow,
      behaviorCustomValue,
    ];
  }

  function buildToggleSectionListItems(filterName, unorderedList, listItemMetadata) {
    log(DEBUG, 'buildListItemToggles()');

    const filter = getFilter(filterName);

    listItemMetadata.forEach(metadata => {
      const { type, name, description } = metadata;

      if (type === 'label') {
        const label = document.createElement('label');
        unorderedList.appendChild(label);

        label.innerText = description;
        label.style.cssText = 'margin: 1em 0em;';

        return;
      }

      const checked = filter[name] || false;

      const listItem = document.createElement('li');
      listItem.classList.add('option');

      const label = document.createElement('label');
      listItem.appendChild(label);

      label.classList.add('option-label', '-toggle', 'switch-control');

      const labelSpan = document.createElement('span');
      label.appendChild(labelSpan);

      labelSpan.classList.add('label');
      labelSpan.innerText = description;

      const labelInput = document.createElement('input');
      label.appendChild(labelInput);

      labelInput.classList.add('checkbox');
      labelInput.setAttribute('type', 'checkbox');
      labelInput.setAttribute('role', 'switch');
      labelInput.setAttribute('data-filter-name', filterName);
      labelInput.setAttribute('data-field-name', name);
      labelInput.checked = checked;

      const labelCheckboxSpan = document.createElement('span');
      label.appendChild(labelCheckboxSpan);

      labelCheckboxSpan.classList.add('state');

      const checkboxTrackSpan = document.createElement('span');
      labelCheckboxSpan.appendChild(checkboxTrackSpan);

      checkboxTrackSpan.classList.add('track');

      const checkboxHandleSpan = document.createElement('span');
      checkboxTrackSpan.appendChild(checkboxHandleSpan);

      checkboxHandleSpan.classList.add('handle');

      unorderedList.appendChild(listItem);
    });
  }

  function buildUserscriptLink(userscriptListItem, addToListLink, addThisFilmLink) {
    const userscriptLink = userscriptListItem.firstElementChild;
    userscriptListItem.onclick = (event) => {
      event.preventDefault();
      log(DEBUG, 'userscriptListItem clicked');

      const link = event.target;

      const id = parseInt(link.getAttribute('data-film-id'));
      const slug = link.getAttribute('data-film-slug');
      const name = link.getAttribute('data-film-name');
      const year = link.getAttribute('data-film-release-year');

      const filmMetadata = {
        id,
        slug,
        name,
        year,
      };

      const titleIsHidden = link.getAttribute('data-title-hidden') === 'true';

      modifyThenObserve(() => {
        if (titleIsHidden) {
          removeFilterFromFilm(filmMetadata);
          removeFromFilmFilter(filmMetadata);
        } else {
          addFilterToFilm(filmMetadata);
          addToHiddenTitles(filmMetadata);
        }

        updateLinkInPopMenu(!titleIsHidden, link);
      });
    };

    const titleId = parseInt(addToListLink.getAttribute('data-film-id'));
    userscriptLink.setAttribute('data-film-id', titleId);

    const filmAction = addToListLink.getAttribute('data-new-list-with-film-action');
    log(VERBOSE, 'filmAction', filmAction);

    const titleSlug = filmAction.split('/').at(-2);
    userscriptLink.setAttribute('data-film-slug', titleSlug);

    const titleName = addThisFilmLink.getAttribute('data-film-name');
    userscriptLink.setAttribute('data-film-name', titleName);
    const titleYear = addThisFilmLink.getAttribute('data-film-release-year');
    userscriptLink.setAttribute('data-film-release-year', titleYear);

    const titleIsHidden = getFilter('filmFilter').some(filteredFilm => filteredFilm.id === titleId);
    updateLinkInPopMenu(titleIsHidden, userscriptLink);

    userscriptLink.removeAttribute('class');

    return userscriptListItem;
  }

  function buildToggleSection(parentElement, sectionTitle, filerName, sectionMetadata) {
    const formRowDiv = document.createElement('div');
    parentElement.appendChild(formRowDiv);

    formRowDiv.style.cssText = 'margin-bottom: 40px;';

    const sectionHeader = document.createElement('h3');
    formRowDiv.append(sectionHeader);

    sectionHeader.classList.add('title-3');
    sectionHeader.style.cssText = 'margin-top: 0em;';
    sectionHeader.innerText = sectionTitle;

    const unorderedList = document.createElement('ul');
    formRowDiv.append(unorderedList);

    unorderedList.classList.add('options-list', '-toggle-list', 'js-toggle-list');

    buildToggleSectionListItems(
      filerName,
      unorderedList,
      sectionMetadata,
    );

    let formColumnDiv = document.createElement('div');
    formRowDiv.appendChild(formColumnDiv);

    formColumnDiv.classList.add('form-columns', '-cols2');
  }

  function createFormRow({
    formRowClass = [],
    formRowStyle = '',
    labelText = '',
    inputValue = '',
    inputType = 'text',
    inputStyle = '',
    selectArray = [],
    selectOnChange = () => {},
    notes = '',
    notesStyle = '',
  }) {
    const formRow = document.createElement('div');
    formRow.classList.add('form-row');
    formRow.style.cssText = formRowStyle;
    formRow.classList.add(...formRowClass);

    const selectList = document.createElement('div');
    formRow.appendChild(selectList);

    selectList.classList.add('select-list');

    const label = document.createElement('label');
    selectList.appendChild(label);

    label.classList.add('label');
    label.textContent = labelText;

    const inputDiv = document.createElement('div');
    selectList.appendChild(inputDiv);

    inputDiv.classList.add('input');
    inputDiv.style.cssText = inputStyle;

    if (inputType === 'select') {
      const select = document.createElement('select');
      inputDiv.appendChild(select);

      select.classList.add('select');

      selectArray.forEach(option => {
        const optionElement = document.createElement('option');
        select.appendChild(optionElement);

        optionElement.value = option;
        optionElement.textContent = option;

        if (option === inputValue) optionElement.setAttribute('selected', 'selected');
      });

      select.onchange = selectOnChange;
    } else if (inputType === 'text') {
      const input = document.createElement('input');
      inputDiv.appendChild(input);

      input.type = 'text';
      input.classList.add('field');
      input.value = inputValue;
    }

    if (notes) {
      const notesElement = document.createElement('p');
      selectList.appendChild(notesElement);

      notesElement.classList.add('notes');
      notesElement.style.cssText = notesStyle;
      notesElement.textContent = notes;
    }

    return formRow;
  }

  function displaySavedBadge() {
    const savedBadge = document.querySelector(`.${SELECTORS.settings.savedBadgeClass}`);

    savedBadge.classList.remove('hidden');
    savedBadge.classList.add('fade');

    setTimeout(() => {
      savedBadge.classList.add('fade-out');
    }, 2000);

    setTimeout(() => {
      savedBadge.classList.remove('fade', 'fade-out');
      savedBadge.classList.add('hidden');
    }, 3000);
  }

  function getFilter(filterName) {
    return JSON.parse(GMC.get(filterName));
  }

  function getFilterBehaviorStyle(filterName) {
    let behaviorStyle;
    let behaviorType = GMC.get(`${filterName}BehaviorType`);
    log(VERBOSE, 'behaviorType', behaviorType);

    const behaviorFadeAmount = GMC.get(`${filterName}BehaviorFadeAmount`);
    log(VERBOSE, 'behaviorFadeAmount', behaviorFadeAmount);

    const behaviorBlurAmount = GMC.get(`${filterName}BehaviorBlurAmount`);
    log(VERBOSE, 'behaviorBlurAmount', behaviorBlurAmount);

    const behaviorCustomValue = GMC.get(`${filterName}BehaviorCustomValue`);
    log(VERBOSE, 'behaviorCustomValue', behaviorCustomValue);

    switch (behaviorType) {
      case 'Remove':
        behaviorStyle = 'display: none !important;';
        break;
      case 'Fade':
        behaviorStyle = `opacity: ${behaviorFadeAmount}%`;
        break;
      case 'Blur':
        behaviorStyle = `filter: blur(${behaviorBlurAmount}px)`;
        break;
      case 'Custom':
        behaviorStyle = behaviorCustomValue;
        break;
    }

    updateBehaviorCSSVariables(filterName, behaviorType);

    return behaviorStyle;
  }

  function gmcInitialized() {
    log(DEBUG, 'gmcInitialized()');

    updateLogLevel();

    log(QUIET, 'Running');

    GMC.css.basic = '';

    if (RESET) {
      log(QUIET, 'Resetting GMC');

      setFilter('filmFilter', []);
      setFilter('reviewFilter', {});
      setFilter('homepageFilter', {});

      GMC.save();
    }

    let userscriptStyle = document.createElement('style');
    userscriptStyle.setAttribute('id', 'filterboxd-style');

    const filmBehaviorStyle = getFilterBehaviorStyle('film');
    log(VERBOSE, 'filmBehaviorStyle', filmBehaviorStyle);

    const reviewBehaviorStyle = getFilterBehaviorStyle('review');
    log(VERBOSE, 'reviewBehaviorStyle', reviewBehaviorStyle);

    userscriptStyle.textContent += `
      .${SELECTORS.filter.filmClass}
      {
        ${filmBehaviorStyle}
      }

      .${SELECTORS.filter.reviewClass}
      {
        ${reviewBehaviorStyle}
      }

      .${SELECTORS.settings.filteredTitleLinkClass}
      {
        cursor: pointer;
        margin-right: 0.3rem !important;
      }

      .${SELECTORS.settings.filteredTitleLinkClass}:hover
      {
        background: #303840;
        color: #def;
      }

      .${SELECTORS.settings.removePendingClass}
      {
        outline: 1px dashed #ee7000;
        outline-offset: -1px;
      }

      .hidden {
        visibility: hidden;
      }

      .fade {
        opacity: 1;
        transition: opacity 1s ease-out;
      }

      .fade.fade-out {
        opacity: 0;
      }
    `;
    document.body.appendChild(userscriptStyle);

    const onSettingsPage = window.location.href.includes('/settings/');
    log(VERBOSE, 'onSettingsPage', onSettingsPage);

    if (onSettingsPage) {
      maybeAddConfigurationToSettings();
    }
    else {
      applyFilters();
      startObserving();
    }
  }

  function maybeAddConfigurationToSettings() {
    log(DEBUG, 'maybeAddConfigurationToSettings()');

    const userscriptTabId = 'tab-filterboxd';
    const configurationExists = document.querySelector(createId(userscriptTabId));
    log(VERBOSE, 'configurationExists', configurationExists);

    if (configurationExists) {
      log(DEBUG, 'Filterboxd configuration tab is present');
      return;
    }

    const userscriptTabDiv = document.createElement('div');

    const settingsTabbedContent = document.querySelector(SELECTORS.settings.tabbedContentId);
    settingsTabbedContent.appendChild(userscriptTabDiv);

    userscriptTabDiv.setAttribute('id', userscriptTabId);
    userscriptTabDiv.classList.add('tabbed-content-block');

    const tabTitle = document.createElement('h2');
    userscriptTabDiv.append(tabTitle);

    tabTitle.style.cssText = 'margin-bottom: 1em;';
    tabTitle.innerText = 'Filterboxd';

    const tabPrimaryColumn = document.createElement('div');
    userscriptTabDiv.append(tabPrimaryColumn);

    tabPrimaryColumn.classList.add('col-10', 'overflow');

    const asideColumn = document.createElement('aside');
    userscriptTabDiv.append(asideColumn);

    asideColumn.classList.add('col-12', 'overflow', 'col-right', 'js-hide-in-app');

    // Filter film page
    const filmPageFilterMetadata = [
      {
        type: 'toggle',
        name: 'backdropImage',
        description: 'Remove backdrop image',
      },
      {
        type: 'label',
        description: 'Left column',
      },
      {
        type: 'toggle',
        name: 'poster',
        description: 'Remove poster',
      },
      {
        type: 'toggle',
        name: 'stats',
        description: 'Remove Letterboxd stats',
      },
      {
        type: 'toggle',
        name: 'whereToWatch',
        description: 'Remove "Where to watch" section',
      },
      {
        type: 'label',
        description: 'Right column',
      },
      {
        type: 'toggle',
        name: 'userActionsPanel',
        description: 'Remove user actions panel',
      },
      {
        type: 'toggle',
        name: 'ratings',
        description: 'Remove "Ratings" section',
      },
      {
        type: 'label',
        description: 'Middle column',
      },
      {
        type: 'toggle',
        name: 'releaseYear',
        description: 'Remove release year text',
      },
      {
        type: 'toggle',
        name: 'director',
        description: 'Remove director text',
      },
      {
        type: 'toggle',
        name: 'tagline',
        description: 'Remove tagline text',
      },
      {
        type: 'toggle',
        name: 'description',
        description: 'Remove description text',
      },
      {
        type: 'toggle',
        name: 'castTab',
        description: 'Remove "Cast" tab',
      },
      {
        type: 'toggle',
        name: 'crewTab',
        description: 'Remove "Crew" tab',
      },
      {
        type: 'toggle',
        name: 'detailsTab',
        description: 'Remove "Details" tab',
      },
      {
        type: 'toggle',
        name: 'genresTab',
        description: 'Remove "Genres" tab',
      },
      {
        type: 'toggle',
        name: 'releasesTab',
        description: 'Remove "Releases" tab',
      },
      {
        type: 'toggle',
        name: 'activityFromFriends',
        description: 'Remove "Activity from friends" section',
      },
      {
        type: 'toggle',
        name: 'filmNews',
        description: 'Remove HQ film news section',
      },
      {
        type: 'toggle',
        name: 'reviewsFromFriends',
        description: 'Remove "Reviews from friends" section',
      },
      {
        type: 'toggle',
        name: 'popularReviews',
        description: 'Remove "Popular reviews" section',
      },
      {
        type: 'toggle',
        name: 'recentReviews',
        description: 'Remove "Recent reviews" section',
      },
      {
        type: 'toggle',
        name: 'relatedFilms',
        description: 'Remove "Related films" section',
      },
      {
        type: 'toggle',
        name: 'similarFilms',
        description: 'Remove "Similar films" section',
      },
      {
        type: 'toggle',
        name: 'mentionedBy',
        description: 'Remove "Mentioned by" section',
      },
      {
        type: 'toggle',
        name: 'popularLists',
        description: 'Remove "Popular lists" section',
      },
    ];

    buildToggleSection(
      asideColumn,
      'Film Page Filter',
      'filmPageFilter',
      filmPageFilterMetadata,
    );

    // Filter films
    const favoriteFilmsDiv = document.querySelector(SELECTORS.settings.favoriteFilms);
    const filteredFilmsDiv = favoriteFilmsDiv.cloneNode(true);
    tabPrimaryColumn.appendChild(filteredFilmsDiv);

    filteredFilmsDiv.style.cssText = 'margin-bottom: 20px;';

    const posterList = filteredFilmsDiv.querySelector(SELECTORS.settings.posterList);
    posterList.remove();

    filteredFilmsDiv.querySelector(SELECTORS.settings.subtitle).innerText = 'Films Filter';
    filteredFilmsDiv.querySelector(SELECTORS.settings.note).innerText =
      'Right click to mark for removal.';

    let hiddenTitlesDiv = document.createElement('div');
    filteredFilmsDiv.append(hiddenTitlesDiv);

    const hiddenTitlesParagraph = document.createElement('p');
    hiddenTitlesDiv.appendChild(hiddenTitlesParagraph);

    hiddenTitlesDiv.classList.add('text-sluglist');

    const filmFilter = getFilter('filmFilter');
    log(VERBOSE, 'filmFilter', filmFilter);

    filmFilter.forEach(filteredFilm => {
      log(VERBOSE, 'filteredFilm', filteredFilm);

      let filteredTitleLink = document.createElement('a');
      hiddenTitlesParagraph.appendChild(filteredTitleLink);

      filteredTitleLink.href= `/film/${filteredFilm.slug}`;

      filteredTitleLink.classList.add(
        'text-slug',
        SELECTORS.processedClass.apply,
        SELECTORS.settings.filteredTitleLinkClass,
      );
      filteredTitleLink.setAttribute('data-film-id', filteredFilm.id);
      filteredTitleLink.innerText = `${filteredFilm.name} (${filteredFilm.year})`;

      filteredTitleLink.oncontextmenu = (event) => {
        event.preventDefault();

        filteredTitleLink.classList.toggle(SELECTORS.settings.removePendingClass);
      };
    });

    let formColumnsDiv = document.createElement('div');
    filteredFilmsDiv.appendChild(formColumnsDiv);

    formColumnsDiv.classList.add('form-columns', '-cols2');

    // Filter films behavior
    const filmBehaviorsMetadata = {
      fade: {
        fieldName: 'filmBehaviorFadeAmount',
      },
      blur: {
        fieldName: 'filmBehaviorBlurAmount',
      },
      replace: {
        fieldName: 'filmBehaviorReplaceValue',
        labelText: 'Direct image URL',
      },
      custom: {
        fieldName: 'filmBehaviorCustomValue',
      },
    };
    const filmFormRows = buildBehaviorFormRows(
      formColumnsDiv,
      'film',
      FILM_BEHAVIORS,
      filmBehaviorsMetadata,
    );

    const clearDiv = filteredFilmsDiv.querySelector(SELECTORS.settings.clear);
    clearDiv.remove();

    // Filter reviews
    const filteredReviewsFormRow = document.createElement('div');
    tabPrimaryColumn.append(filteredReviewsFormRow);

    filteredReviewsFormRow.classList.add('form-row');

    const filteredReviewsTitle = document.createElement('h3');
    filteredReviewsFormRow.append(filteredReviewsTitle);

    filteredReviewsTitle.classList.add('title-3');
    filteredReviewsTitle.style.cssText = 'margin-top: 0em;';
    filteredReviewsTitle.innerText = 'Reviews Filter';

    const filteredReviewsUnorderedList = document.createElement('ul');
    filteredReviewsFormRow.append(filteredReviewsUnorderedList);

    filteredReviewsUnorderedList.classList.add('options-list', '-toggle-list', 'js-toggle-list');

    const reviewFilterItems = [
      {
        name: 'ratings',
        description: 'Remove ratings from reviews',
      },
      {
        name: 'likes',
        description: 'Remove likes from reviews',
      },
      {
        name: 'comments',
        description: 'Remove comments from reviews',
      },
      {
        name: 'withSpoilers',
        description: 'Filter reviews that contain spoilers',
      },
      {
        name: 'withoutRatings',
        description: 'Filter reviews that don\'t have ratings',
      },
    ];

    buildToggleSectionListItems(
      'reviewFilter',
      filteredReviewsUnorderedList,
      reviewFilterItems,
    );

    let reviewColumnsDiv = document.createElement('div');
    filteredReviewsFormRow.appendChild(reviewColumnsDiv);

    reviewColumnsDiv.classList.add('form-columns', '-cols2');

    const reviewBehaviorsMetadata = {
      fade: {
        fieldName: 'reviewBehaviorFadeAmount',
      },
      blur: {
        fieldName: 'reviewBehaviorBlurAmount',
      },
      replace: {
        fieldName: 'reviewBehaviorReplaceValue',
        labelText: 'Text',
      },
      custom: {
        fieldName: 'reviewBehaviorCustomValue',
      },
    };
    const reviewFormRows = buildBehaviorFormRows(
      reviewColumnsDiv,
      'review',
      REVIEW_BEHAVIORS,
      reviewBehaviorsMetadata,
    );

    // Filter homepage
    const homepageFilterMetadata = [
      {
        name: 'friendsHaveBeenWatching',
        description: 'Remove "Here\'s what your friends have been watching..." title text',
      },
      {
        name: 'newFromFriends',
        description: 'Remove "New from friends" films section',
      },
      {
        name: 'popularWithFriends',
        description: 'Remove "Popular with friends" section',
      },
      {
        name: 'discoveryStream',
        description: 'Remove discovery section (e.g. festivals, competitions)',
      },
      {
        name: 'latestNews',
        description: 'Remove "Latest news" section',
      },
      {
        name: 'popularReviewsWithFriends',
        description: 'Remove "Popular reviews with friends" section',
      },
      {
        name: 'newListsFromFriends',
        description: 'Remove "New from friends" lists section',
      },
      {
        name: 'popularLists',
        description: 'Remove "Popular lists" section',
      },
      {
        name: 'recentStories',
        description: 'Remove "Recent stories" section',
      },
      {
        name: 'recentShowdowns',
        description: 'Remove "Recent showdowns" section',
      },
      {
        name: 'recentNews',
        description: 'Remove "Recent news" section',
      },
    ];

    buildToggleSection(
      tabPrimaryColumn,
      'Homepage Filter',
      'homepageFilter',
      homepageFilterMetadata,
    );

    // Save changes
    let buttonsRowDiv = document.createElement('div');
    userscriptTabDiv.appendChild(buttonsRowDiv);

    buttonsRowDiv.style.cssText = 'display: flex; align-items: center;';
    buttonsRowDiv.classList.add('buttons', 'clear', 'row');

    let saveInput = document.createElement('input');
    buttonsRowDiv.appendChild(saveInput);

    saveInput.classList.add('button', 'button-action');
    saveInput.setAttribute('value', 'Save Changes');
    saveInput.setAttribute('type', 'submit');
    saveInput.onclick = (event) => {
      event.preventDefault();

      const pendingRemovals = hiddenTitlesParagraph.querySelectorAll(`.${SELECTORS.settings.removePendingClass}`);
      pendingRemovals.forEach(removalLink => {
        const id = parseInt(removalLink.getAttribute('data-film-id'));
        const filteredFilm = filmFilter.find(filteredFilm => filteredFilm.id === id);

        removeFilterFromFilm(filteredFilm);
        removeFromFilmFilter(filteredFilm);
        removalLink.remove();
      });

      saveBehaviorSettings('film', filmFormRows);
      saveBehaviorSettings('review', reviewFormRows);

      const inputToggles = userscriptTabDiv.querySelectorAll('input[type="checkbox"]');
      inputToggles.forEach(inputToggle => {
        const filterName = inputToggle.getAttribute('data-filter-name');
        const filter = getFilter(filterName);

        const fieldName = inputToggle.getAttribute('data-field-name');
        const checked = inputToggle.checked;

        filter[fieldName] = checked;
        setFilter(filterName, filter);
      });

      displaySavedBadge();
    };

    let checkContainerDiv = document.createElement('div');
    buttonsRowDiv.appendChild(checkContainerDiv);

    checkContainerDiv.classList.add('check-container');
    checkContainerDiv.style.cssText = 'margin-left: 10px;';

    let usernameAvailableParagraph = document.createElement('p');
    checkContainerDiv.appendChild(usernameAvailableParagraph);

    usernameAvailableParagraph.classList.add(
      'username-available',
      'has-icon',
      'hidden',
      SELECTORS.settings.savedBadgeClass,
    );
    usernameAvailableParagraph.style.cssText = 'float: left;';

    let iconSpan = document.createElement('span');
    usernameAvailableParagraph.appendChild(iconSpan);

    iconSpan.classList.add('icon');

    const savedText = document.createTextNode('Saved');
    usernameAvailableParagraph.appendChild(savedText);

    const settingsSubNav = document.querySelector(SELECTORS.settings.subNav);

    const userscriptSubNabListItem = document.createElement('li');
    settingsSubNav.appendChild(userscriptSubNabListItem);

    const userscriptSubNabLink = document.createElement('a');
    userscriptSubNabListItem.appendChild(userscriptSubNabLink);

    const userscriptSettingsLink = '/settings/?filterboxd';
    userscriptSubNabLink.setAttribute('href', userscriptSettingsLink);
    userscriptSubNabLink.setAttribute('data-id', 'filterboxd');
    userscriptSubNabLink.innerText = 'Filterboxd';
    userscriptSubNabLink.onclick = (event) => {
      event.preventDefault();

      Array.from(settingsSubNav.children).forEach(listItem => {
        const link = listItem.querySelector('a');

        if (link.getAttribute('data-id') === 'filterboxd') {
          listItem.classList.add('selected');
        } else {
          listItem.classList.remove('selected');
        }
      });

      Array.from(settingsTabbedContent.children).forEach(tab => {
        if (!tab.id) return;

        const display = tab.id === userscriptTabId ? 'block' : 'none';
        tab.style.cssText = `display: ${display};`;
      });

      window.history.replaceState(null, '', `https://letterboxd.com${userscriptSettingsLink}`);
    };

    Array.from(settingsSubNav.children).forEach(listItem => {
      listItem.onclick = (event) => {
        const link = event.target;
        if (link.getAttribute('href') === userscriptSettingsLink) return;

        userscriptSubNabListItem.classList.remove('selected');
        userscriptTabDiv.style.display = 'none';
      };
    });

    const urlParams = new URLSearchParams(window.location.search);
    const tabSelected = urlParams.get('filterboxd') !== null;
    log(VERBOSE, 'tabSelected', tabSelected);

    if (tabSelected) window.onload = () => userscriptSubNabLink.click();
  }

  function maybeAddListItemToSidebar() {
    log(DEBUG, 'maybeAddListItemToSidebar()');

    const userscriptListItemId = document.querySelector(createId(SELECTORS.userpanel.userscriptListItemId));
    if (userscriptListItemId) {
      log(DEBUG, 'Userscript list item already exists');
      return false;
    }

    const userpanel = document.querySelector(SELECTORS.userpanel.self);

    if (!userpanel) {
      log(INFO, 'Userpanel not found');
      return false;
    }

    const secondLastListItem = userpanel.querySelector('li:nth-last-child(2)');
    if (!secondLastListItem ) {
      log(INFO, 'Second last list item not found');
      return false;
    }

    if (secondLastListItem.classList.contains('loading-csi')) {
      log(INFO, 'Second last list item is loading');
      return false;
    }

    let userscriptListItem = secondLastListItem.cloneNode(true);
    userscriptListItem.setAttribute('id', SELECTORS.userpanel.userscriptListItemId);

    const addToListLink = secondLastListItem.firstElementChild;
    const addThisFilmLink = userpanel.querySelector(SELECTORS.userpanel.addThisFilm);

    userscriptListItem = buildUserscriptLink(userscriptListItem, addToListLink, addThisFilmLink);

    secondLastListItem.parentNode.insertBefore(userscriptListItem, userpanel.querySelector('li:nth-last-of-type(1)'));

    return true;
  }

  function removeFilterFromElement(element, levelsUp = 0) {
    log(DEBUG, 'removeFilterFromElement()');

    const replaceBehavior = GMC.get('filmBehaviorType') === 'Replace poster';
    log(VERBOSE, 'replaceBehavior', replaceBehavior);

    if (replaceBehavior) {
      const originalImgSrc = element.getAttribute('data-original-img-src');
      if (!originalImgSrc) return;

      element.querySelector('img').src = originalImgSrc;
      element.querySelector('img').srcset = originalImgSrc;

      element.removeAttribute('data-original-img-src');
      element.classList.add(SELECTORS.processedClass.remove);
    } else {
      let target = element;

      for (let i = 0; i < levelsUp; i++) {
        if (target.parentNode) {
          target = target.parentNode;
        } else {
          break;
        }
      }

      log(VERBOSE, 'target', target);

      target.classList.remove(SELECTORS.filter.filmClass);
      element.classList.add(SELECTORS.processedClass.remove);
    }
  }

  function removeFromFilmFilter(filmMetadata) {
    let filmFilter = getFilter('filmFilter');
    filmFilter = filmFilter.filter(filteredFilm => filteredFilm.id !== filmMetadata.id);

    setFilter('filmFilter', filmFilter);
  }

  function removeFilterFromFilm({ id, slug }) {
    log(DEBUG, 'removeFilterFromFilm()');

    const idMatch = `[data-film-id="${id}"]`;
    let removedSelector = `.${SELECTORS.processedClass.remove}`;

    // Activity page reviews
    document.querySelectorAll(`section.activity-row ${idMatch}`).forEach(posterElement => {
      removeFilterFromElement(posterElement, 3);
    });

    // Activity page likes
    document.querySelectorAll(`section.activity-row .activity-summary a[href*="${slug}"]:not(${removedSelector})`).forEach(posterElement => {
      removeFilterFromElement(posterElement, 3);
    });

    // New from friends
    document.querySelectorAll(`.poster-container ${idMatch}:not(${removedSelector})`).forEach(posterElement => {
      removeFilterFromElement(posterElement, 1);
    });

    // Reviews
    document.querySelectorAll(`.review-tile ${idMatch}:not(${removedSelector})`).forEach(posterElement => {
      removeFilterFromElement(posterElement, 3);
    });

    // Diary
    document.querySelectorAll(`.td-film-details [data-original-img-src]${idMatch}:not(${removedSelector})`).forEach(posterElement => {
      removeFilterFromElement(posterElement, 2);
    });

    // Popular with friends, competitions
    const remainingElements = document.querySelectorAll(
      `div:not(.popmenu):not(.actions-panel) ${idMatch}:not(aside [data-film-id="${id}"]):not(${removedSelector})`,
    );
    remainingElements.forEach(posterElement => {
      removeFilterFromElement(posterElement, 0);
    });
  }

  function saveBehaviorSettings(filterName, formRows) {
    const behaviorType = formRows[0].querySelector('select').value;
    log(DEBUG, 'behaviorType', behaviorType);

    GMC.set(`${filterName}BehaviorType`, behaviorType);

    updateBehaviorCSSVariables(filterName, behaviorType);

    if (behaviorType === 'Fade') {
      const behaviorFadeAmount = formRows[1].querySelector('select').value;
      log(DEBUG, 'behaviorFadeAmount', behaviorFadeAmount);

      GMC.set(`${filterName}BehaviorFadeAmount`, behaviorFadeAmount);
    } else if (behaviorType === 'Blur') {
      const behaviorBlurAmount = formRows[2].querySelector('select').value;
      log(DEBUG, 'behaviorBlurAmount', behaviorBlurAmount);

      GMC.set(`${filterName}BehaviorBlurAmount`, behaviorBlurAmount);
    } else if (behaviorType.includes('Replace')) {
      const behaviorReplaceValue = formRows[3].querySelector('input').value;
      log(DEBUG, 'behaviorReplaceValue', behaviorReplaceValue);

      GMC.set(`${filterName}BehaviorReplaceValue`, behaviorReplaceValue);
    } else if (behaviorType === 'Custom') {
      const behaviorCustomValue = formRows[4].querySelector('input').value;
      log(DEBUG, 'behaviorCustomValue', behaviorCustomValue);

      GMC.set(`${filterName}BehaviorCustomValue`, behaviorCustomValue);
    }

    GMC.save();
  }

  function setFilter(filterName, filterValue) {
    GMC.set(filterName, JSON.stringify(filterValue));
    return GMC.save();
  }

  function updateBehaviorCSSVariables(filterName, behaviorType) {
    log(DEBUG, 'updateBehaviorTypeVariable()');

    const fadeValue = behaviorType === 'Fade' ? 'block' : 'none';
    document.documentElement.style.setProperty(
      `--filterboxd-${filterName}-behavior-fade`,
      fadeValue,
    );

    const blurValue = behaviorType === 'Blur' ? 'block' : 'none';
    document.documentElement.style.setProperty(
      `--filterboxd-${filterName}-behavior-blur`,
      blurValue,
    );

    const replaceValue = behaviorType.includes('Replace') ? 'block' : 'none';
    document.documentElement.style.setProperty(
      `--filterboxd-${filterName}-behavior-replace`,
      replaceValue,
    );

    const customValue = behaviorType === 'Custom' ? 'block' : 'none';
    document.documentElement.style.setProperty(
      `--filterboxd-${filterName}-behavior-custom`,
      customValue,
    );
  }

  function updateLinkInPopMenu(titleIsHidden, link) {
    log(DEBUG, 'updateLinkInPopMenu()');

    link.setAttribute('data-title-hidden', titleIsHidden);

    const innerText = titleIsHidden ? 'Remove from filter' : 'Add to filter';
    link.innerText = innerText;
  }

  let OBSERVER = new MutationObserver(observeAndModify);

  let GMC = new GM_config({
    id: 'gmc-frame',
    events: {
      init: gmcInitialized,
    },
    fields: {
      filmBehaviorType: {
        type: 'select',
        options: FILM_BEHAVIORS,
        default: 'Fade',
      },
      filmBehaviorBlurAmount: {
        type: 'int',
        default: 3,
      },
      filmBehaviorCustomValue: {
        type: 'text',
        default: '',
      },
      filmBehaviorFadeAmount: {
        type: 'int',
        default: 10,
      },
      filmBehaviorReplaceValue: {
        type: 'text',
        default: 'https://raw.githubusercontent.com/blakegearin/filterboxd/main/img/bee-movie.jpg',
      },
      filmFilter: {
        type: 'text',
        default: JSON.stringify([]),
      },
      filmPageFilter: {
        type: 'text',
        default: JSON.stringify({}),
      },
      homepageFilter: {
        type: 'text',
        default: JSON.stringify({}),
      },
      logLevel: {
        type: 'select',
        options: [
          'silent',
          'quiet',
          'debug',
          'verbose',
          'trace',
        ],
        default: 'quiet',
      },
      reviewBehaviorType: {
        type: 'select',
        options: REVIEW_BEHAVIORS,
        default: 'Fade',
      },
      reviewBehaviorBlurAmount: {
        type: 'int',
        default: 3,
      },
      reviewBehaviorCustomValue: {
        type: 'text',
        default: '',
      },
      reviewBehaviorFadeAmount: {
        type: 'int',
        default: 10,
      },
      reviewBehaviorReplaceValue: {
        type: 'text',
        default: 'According to all known laws of aviation, there is no way a bee should be able to fly.',
      },
      reviewFilter: {
        type: 'text',
        default: JSON.stringify({}),
      },
    },
  });
})();