Dev to master (#5002)

* fix(4K) :4K request fixes (#4702)

* GetRequestsByStatus wasn't implementing the MovieRequests object correctly for 4K quality requests with the ProcessingRequest status.

* Fixed 4K requests not getting automatically approved if the user has the "Auto Approve Movie" role flag enabled.

* Fixed "Request Date" values for the "left-panel-details" div class. Previously when the movie was exclusively 4K (regular request was absent), then "Request Date" equaled DateTime.MinValue (January 1, 0001).

* Fixed "Request Status" evaluation in the "left-panel-details" div class. Now it shows the appropriate status instead of an empty spot. "Request Status" displays both regular and 4K statuses at the same time if needed. Added a comma to the end of the "RequestStatus" label to maintain design consistency with the other labels. Also added a "Denied Reason" element for 4K  requests.

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.22.3 [skip ci]

* chore: Storybook (#4700)

[skip ci]

* chore: Translations

[skip ci]

* 🌐 Translations Update (#4704)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci] (#4713)

* build: Run automation tests in docker (#4715)

[skip ci]

* fix: fixed trakt image not loading when base url present (#4711)

[skip ci]

* fix: 🐛 Fixed missing externals (#4712)

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.22.4 [skip ci]

* test: fixed automationt tests [skip ci]

* fix: Log Microsoft warnings to log file (#4723)

[skip ci]

* feat:  Recently Requested on Discover Page (#4387)

* chore(release): 🚀 v4.23.0 [skip ci]

* fix: Localize recently requested on discover page (#4729)

[skip ci]

* 🌐 Translations Update (#4731)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* Fix: Ombi.Api.Lidarr: Remove unused fields from ArtistAdd (#4727)

When an artist is not found in Lidarr as part of requesting an album,
MusicSender will make a POST request against the /api/v1/artist endpoint
to add such artist.

Not all fields defined in ArtistAdd are initialized, and those
uninitialized will be `null` values in the JSON body of the request, as
shown in this intercepted request:

```
{
    "addOptions": {
        "AlbumsToMonitor": [
            "e5c48b66-44ef-3685-ad53-45dbcd7294c0"
        ],
        "monitor": 6,
        "monitored": true,
        "searchForMissingAlbums": false
    },
    "added": "2022-08-10T06:49:32.4374278+00:00",
    "albumFolder": true,
    "artistName": "Manolo García",
    "cleanName": "manologarcía",
    "disambiguation": null,
    "discogsId": 0,
    "ended": false,
    "foreignArtistId": "1c8309da-9789-40bf-b9c2-e20064263820",
    "images": [],
    "links": [],
    "metadataProfileId": 1,
    "monitored": true,
    "overview": null,
    "qualityProfileId": 3,
    "ratings": null,
    "remotePoster": null,
    "rootFolderPath": "/media/music/",
    "sortName": null,
    "statistics": null,
    "status": null,
    "tadbId": 0,
    "tags": null
}
```

This request will fail and Lidarr will return a 400 BadRequest error
with the following message:

```
2022-08-10 01:45:52.458 +00:00 [Error] StatusCode: BadRequest, Reason: Bad Request, RequestUri: http://lidarr:8686/api/v1/artist
2022-08-10 01:45:52.459 +00:00 [Debug] {
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "00-52e01b322a05d7c6633eca2488ef2a5c-06345b3bb8c4bb6c-00",
  "errors": {
    "$.status": [
      "The JSON value could not be converted to NzbDrone.Core.Music.ArtistStatusType. Path: $.status | LineNumber: 0 | BytePositionInLine: 14."
    ]
  }
}
```

Removing all the `null` fields from the JSON body fixes the problem and
correctly adds the artist to Lidarr.

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.23.1 [skip ci]

* fix: Fix conflicting property name for Swagger (#4733)

* chore(release): 🚀 v4.23.2 [skip ci]

* feat: add crew on movie page (#4722)

* add crew on movie page

* order by director, add default image and fix click

Co-authored-by: tidusjar <tidusjar@gmail.com>

* 🌐 Translations Update (#4736)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* chore(release): 🚀 v4.24.0 [skip ci]

* feat: Watchlist history errors(#4741)

[skip ci]

* fix: fixed stats controller (#4742)

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.25.0 [skip ci]

* fix(webhook): Remove added trailing slash from webhook URL #4710

* chore(release): 🚀 v4.25.1 [skip ci]

* feat(notifications): Add more curly variables for partially available notification

* feat: Add more curly variables for partially available notification

* test: Fix newly added test

* chore(release): 🚀 v4.26.0 [skip ci]

* feat: Recently requested improvements (#4755)

* feat(discover):  Admins can now approve the Recently Requested list

* feat(discover):  Images for the recently requested area are now loading faster and just better all around

* test:  Added automation for the new feature

* chore(release): 🚀 v4.27.0 [skip ci]

* fix(plex): stop the plex sync from deleting episodes when we can't find the plex key

* chore(release): 🚀 v4.27.1 [skip ci]

* refactor: Encapsulate common TV availability checker logic (#4753)

[skip ci]

* fix(sonarr): 🐛 Cleaned up and removed Sonarr v3 option, sonarr v3 is now the default. This allows us to get ready for the upcoming Sonarr v4 (#4764)

* chore(release): 🚀 v4.27.2 [skip ci]

* 🌐 Translations Update (#4739)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(availability): 🐛 Fixed a issue with the availability checker after the previous update. Added full test coverage around that area

* chore(release): 🚀 v4.27.3 [skip ci]

* test: 🧪 added full test coverage to the plex availability checker, also fixed a small few bugs in there at the same time

* chore(release): 🚀 v4.27.4 [skip ci]

* fix(importer): 🐛 Allow you to only import Plex Admins without the Plex Users

* chore(release): 🚀 v4.27.5 [skip ci]

* fix(notifications): Fixed the error when sending multiple test notifications. Added more logging when Discord complains the message is invalid

* chore(release): 🚀 v4.27.6 [skip ci]

* fix: Fixes default image for recently requested items. (#4767)

* chore(release): 🚀 v4.27.7 [skip ci]

* refactor: Upgrades nuget packages. Removes deprecated packages. Fixes build warnings. (#4769)

* Upgrades nuget packages. Removes deprecated packages. Fixes build warnings.

* Fixes the last few build warnings.

* chore(release): 🚀 v4.27.8 [skip ci]

* refactor: Rework the Plex Settings Page (#4772)

[skip ci]

* feat(plex):  Added the ability to configure the watchlist to request the whole TV show rather than latest season (#4774)

* chore(release): 🚀 v4.28.0 [skip ci]

* 🌐 Translations Update (#4771)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* 🌐 Translations Update (#4775)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix: Reworked the version check (#4719) (#4781)

[skip ci]

* fix(plex): 🐛 Fixed not being able to enable watchlist requests in the Plex settings

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.28.1 [skip ci]

* feat: Provide a flag for missing users on Plex Server (#4688) (#4778)

[skip ci]

* fix: Unable to Delete Jellyfin Server (#4705) (#4780)

[skip ci]

* fix: Partially Available prevents further TV requests (#4768) (#4779)

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.29.0 [skip ci]

* fix: Consistently reset loading flag when requesting movies on discover page. (#4777)

[skip ci]

* fix(sonarr): 🐛 Fixed an issue where the language list didn't correctly load for power users in the advanced options #4782

* chore(release): 🚀 v4.29.1 [skip ci]

* fix(plex): Fixed an issue where sometimes the availability checker would throw an exception when checking episodes

* chore: fixed tests

* chore(release): 🚀 v4.29.2 [skip ci]

* fix: Only log error messages from Microsoft (#4787)

[skip ci]

* 🌐 Translations Update (#4784)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(notifications): Fixed the Partially TV notifications going to the admin #4797 (#4799)

* chore(release): 🚀 v4.29.3 [skip ci]

* feat(sonarr):  Add the username to a Sonarr tag when sent to Sonarr (#4802)

* chore(release): 🚀 v4.30.0 [skip ci]

* feat(sonarr): Added the ability to add default tags when sending to Sonarr (#4803)

* chore(release): 🚀 v4.31.0 [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci] (#4801)

* feat(plex): Rework the Plex Settings page (#4805)

* chore(release): 🚀 v4.32.0 [skip ci]

* fix(plex): 🐛 Fixed the issue where you couldn't add a new server on a fresh setup after the settings page rework

* chore(release): 🚀 v4.32.1 [skip ci]

* fix(sonarr): 🐛 Sonarr V4 should work now (#4810)

* fix(sonarr): 🐛 Sonarr V4 should work now

Auto detect the sonarr version and adjust the UI depending on V3 or V4 (Lang profiles)

* fix: Fixed the load error

* chore(release): 🚀 v4.32.2 [skip ci]

* fix(sonarr): V4 actually works this time around

* chore(release): 🚀 v4.32.3 [skip ci]

* feat: Angular 15 and Dependency upgrades (#4818)

* chore(release): 🚀 v4.33.0 [skip ci]

* fix(plex): Added the watchlist request whole show back into the settings

* chore: undid

* fixed (#4833)

* chore(release): 🚀 v4.33.1 [skip ci]

* chore: add logo [skip ci]

* feat: Radarr tags (#4815)

* chore(release): 🚀 v4.34.0 [skip ci]

* fix(plex-watchlist): Lookup the ID from different sources when Plex doesn't contain the metadata (#4843)

* chore(release): 🚀 v4.34.1 [skip ci]

* feat: Add the option for header authentication to create users (#4841)

* feat: allow SSO to create new users automatically

* feat: apply default user settings to SSO users

* feat: add warnings to header auth toggles

* chore(release): 🚀 v4.35.0 [skip ci]

* fix(plex-watchlist): Index out of bounds error

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.35.1 [skip ci]

* fix(database): Just some tweaks, shouldn't notice any difference, maybe a less error in the log

* chore(release): 🚀 v4.35.2 [skip ci]

* fix(#4847): Invalid Discord request fixed, also fixed an issue where App Only users would not show as logged in on the user management page (#4848)

* chore(release): 🚀 v4.35.3 [skip ci]

* bug(#4854): 🐛 Fixed the Recently Requested showing requests when it should be hidden

* fix(discover): 🐛 Fixed the default poster not taking into account the base url in some scenarios #4845

* fix(Hide music from navbar and request list when not enabled): 🐛

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.35.4 [skip ci]

* fix(radarr-settings): 🐛 Fixed a typo

* chore(release): 🚀 v4.35.5 [skip ci]

* fix: Fixed the issue where the login page is still present after logging in with oauth

* chore(release): 🚀 v4.35.6 [skip ci]

* fix(wizard): 🐛 Stop access to the wizard when you have already setup ombi (#4866)

* chore(release): 🚀 v4.35.7 [skip ci]

* fix(plex-oauth): 🐛 Fixed an issue where using OAuth you could log in as a Ombi Local user #4835

* chore(release): 🚀 v4.35.8 [skip ci]

* chore: 👥 Updated Contributors [skip ci]

* fixed bad merge

* chore(release): 🚀 v4.35.9 [skip ci]

* Update .gitignore

* Fixed automation

* fix(sonarr): 🐛 Improved the error handling in the sonarr settings page in the UI

This should hopefully prevent some odd situations where the settings are in a odd state #4877

* chore: update deps

* chore: more deps

* bump

* chore(release): 🚀 v4.35.12 [skip ci]

* fix(sonarr): 🐛 Added some more error handling and information around testing sonarr

#4877

* chore(release): 🚀 v4.35.13 [skip ci]

* fix: Some minor tweaks to the movie info panel (#4883)

* fix: Hide denied reason label if there is no value

* fix: Movie would show as pending approval when denied

* chore(release): 🚀 v4.35.14 [skip ci]

* fix(sonarr): 🐛 Stop the sonarr version endpoint from breaking when Sonarr is down #4895

* chore(release): 🚀 v4.35.15 [skip ci]

* fix: Support duplicates in Emby/JF collections (#4902)

Support same movie that belongs in different collections
in Emby or Jellyfin

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.35.16 [skip ci]

* fix(discover): Fix denied requests displayed as approved (#4901)

* fix: Fix denied movie shown as 'processing request' in details view (#4900)

* chore(release): 🚀 v4.35.17 [skip ci]

* fix(#4906): 🐛 Fixed an issue with power users and permissions

* chore(release): 🚀 v4.35.18 [skip ci]

* fix(radarr): Fixed an issue where the radarr sync would break

* chore(release): 🚀 v4.35.19 [skip ci]

* feat(discover): Add deny option to recently requested (#4907)

* chore(release): 🚀 v4.36.0 [skip ci]

* fix(healthchecks): Removed redundant ping check

* chore(release): 🚀 v4.36.1 [skip ci]

* feat: Search by genre

[skip-ci]

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.37.0 [skip ci]

* fix(discover): 🚸 Improved the new Genre buttons, it now includes TV results

* chore: 👥 Updated Contributors [skip ci]

* fix: Cron Validation (#4842)

* Add Cron Next Time Validation

The cron job can't be created if the year is more than 100 years in the future.
Getting the next valid time will return null if this is the case.

* add next cron validation to api
* add next cron validation to job settings page

* Add Missing Import

* chore: 👥 Updated Contributors [skip ci]

* 🌐 Translations Update (#4806)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(lidarr): Change monitor to Existing to properly add artist #3597

Discussed and tested manually in https://github.com/Lidarr/Lidarr/issues/3597#issuecomment-1530804055

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.37.1 [skip ci]

* fix(jellyfin): Fixed an issue where the sync could stop working. Removed unused properties so the deseralization no longer fails

* chore(release): 🚀 v4.37.2 [skip ci]

* fix: Show the ApiAlias in the requests-list

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.37.3 [skip ci]

* feat(emby): Show watched status for Movie requests

* First step towards played sync

* Change TMDB id format to integer

This will better integrate with TMDB id type in the request model

* Display played state in the requests list

* Fix played status filter

* Run played sync job after content sync instead of on its own

* Add a toggle to activate played sync

* Hoovering

* FIx played sync job not being triggered

* Expose played state according to hide requests setting

* Fix tests

* Fix tests for real

* Add MySql migrations

[skip ci]

* fix: remove sort header

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.38.0 [skip ci]

* fix(notificaitons): Add the RequestedByAlias field to the Notification Message

* chore: 👥 Updated Contributors [skip ci]

* 🌐 Translations Update (#4921)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(API): Allow RequestOnBehalf rights if requested from the API (#4919)

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.38.1 [skip ci]

* Merge pull request from GHSA-28j3-84m7-gpjp

* chore(release): 🚀 v4.38.2 [skip ci]

* Develop master (#4930)

* Update (#4871)

* Remove dead code

* Localize TV requests messages on TV details page

* Transform buttons with link into anchors

* Sonarr sync: stop using seasonpass API

* chore(release): 🚀 v4.16.13

* Fix requests when 4k available and 4k disabled

Fixes #4610

* chore(release): 🚀 v4.16.14

* Hide subscribe button when request is available

* Hide subscribe button when request is denied

* Add Title to Partially Available Message

If the Title of the show is not menitoned it can be unclear what Episodes are now available.

* Better error message when test email fails due to missing recipient

* feat(discover): Add original language filter

* chore(release): 🚀 v4.16.15

* fix(4616): 🐛 fixed mandatory fields

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.16.16

* added test results into the PR pipeline

* chore(release): 🚀 v4.16.17

* Add information about cache refresh

* Update pr.yml

[skip ci]

* Update pr.yml

[skip ci]

* Update pr.yml

[skip ci]

* chore(release): 🚀 v4.17.0

* feat(discover): Add new trending source experimental feature

* fix(settings): Allow toggling features when there are more than one

* fix(discover): Fix new trending feature detection

* fix(discover): Fix cache mix up

* refactor(discover): Move movie trending feature toggle to backend

* feat(discover): Default trending source to new logic

* chore(release): 🚀 v4.18.0

* feat(sync): Detect reidentified movies in Emby and Jellyfin

* feat(sync): Detect reidentified series in Emby and Jellyfin

* Fix sync log criticity

* Update pr.yml

[skip ci]

* Update label.yml

[skip ci]

* Fix formatting

* Update pr.yml

[skip ci]

* Update label.yml

[skip ci]

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.19.0

* refactor(newsletter): Clarify very rare cases where newsletter doesn't publish a series

* refactor(newsletter): Clarify very rare cases where newsletter doesn't publish movie

* chore(release): 🚀 v4.19.1

* feat(discover): Show more relevant shows in upcoming TV

* chore(release): 🚀 v4.20.0

* fix(sync): Emby+Jellyfin - sync multi-episode files of 3+ episodes

* perf(sync): Emby+Jellyfin - use a more reliable filter to missing items

* fix(sync): Emby+Jellyfin - sync multi-episode files of 3+ episodes [skip ci]

* fix: added media type tag to media type text (#4638)

[skip ci]

* fix(sickrage): Fixed issue with incorrect handling of SiCKRAGE episode results returned during episode status changes, now expects array of objects from data path if present (#4648)

[skip ci]

* fix: Missing Poster broken link fix (#4637)

[skip ci]

* 🌐 Translations Update (#4622)

[skip ci]

* Update launch.json (#4650)

[skip ci]

* fix: Improve Swagger documentation (#4652)

* Upgrade Swashbuckle dependency

* Document /token response

* Add support for Newtonsoft annotations in Swagger

* Remove unecessary ActionResult [skip ci]

* fix(API): Fix pagination in some edge cases (#4649)

[skip ci]

* 🌐 Translations Update (#4655)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(discover): Carousel touch not working when scrolling page and recommendations and similar movie navigation (#4633)

* fixed touch not working on carousels

* fixed touch not working

* Movie details component fixes

Fixed recommendations and similar not changing the data on the component by calling the init function again on param change

Moved the ngif results > 0 to the mat-expansion panel to avoid rendering  the entire element if it doesn't have any results instead of having an empty panel.

* removed unused line, added scroll to top on init

* updated recommendation refresh implementation

Changed the implementation to use the router instead in order to reload the component instead of just reloading the data.

This implementation makes sure the component gets destroyed on navigation eliminating any memory leaks, reloading CSS in case of having animations on page load and generally a continuation of the experience you get when you browse into a movie from the discover page.

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.20.1 [skip ci]

* fix: 🐛 Fixed the Request on Behalf of having blanks (#4667)

* chore(release): 🚀 v4.20.2 [skip ci]

* fix(plex): 🐛 Fixed an issue with the Plex Sync

* chore(release): 🚀 v4.20.3 [skip ci]

* fix (technical): Improved some of the date time parsing handling

* fix: fixed build

* chore(release): 🚀 v4.20.4 [skip ci]

* feat: Upgrade to Angular14 (#4668)

* refactor: 🔥 removed angular-bootstrap-md dependancy

* chore: update tsconfig

* yeah

* ng14 upgrade

* refactor: migration changes

* fix: fixed CLI

* test: Fixed automation

* chore: 👥 Updated Contributors [skip ci]

* perf: stop populating obsolete subscribe fields (#4625)

* chore(release): 🚀 v4.21.0 [skip ci]

* fix(images): Retry images with a backoff when we get a Too Many requests from TheMovieDb #4685

* chore(release): 🚀 v4.21.1 [skip ci]

* 🌐 Translations Update (#4683)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix: Landing and Login page improvements (#4690)

* chore(release): 🚀 v4.21.2 [skip ci]

* feat(discover):  Added infinite scroll on advanced search results

* feat(discover):  Added infinite scroll on advanced search results

* chore(release): 🚀 v4.22.0 [skip ci]

* 🌐 Translations Update (#4694)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(discover): 🐛 Created new Image component to handle 429's from TMDB (#4698) and fixed #4635 (#4699)

* chore(release): 🚀 v4.22.1 [skip ci]

* fix: fixed an issue where I broke images for some users

* chore(release): 🚀 v4.22.2 [skip ci]

* ci(Mergify): configuration update (#4701)

Signed-off-by: Jamie <tidusjar@gmail.com> [skip ci]

* fix: Override Sonarr V3 Profiles endpoint (#4678)

* Override Sonarr V3 Profiles endpoint [skip ci]

* fix(4K) :4K request fixes (#4702)

* GetRequestsByStatus wasn't implementing the MovieRequests object correctly for 4K quality requests with the ProcessingRequest status.

* Fixed 4K requests not getting automatically approved if the user has the "Auto Approve Movie" role flag enabled.

* Fixed "Request Date" values for the "left-panel-details" div class. Previously when the movie was exclusively 4K (regular request was absent), then "Request Date" equaled DateTime.MinValue (January 1, 0001).

* Fixed "Request Status" evaluation in the "left-panel-details" div class. Now it shows the appropriate status instead of an empty spot. "Request Status" displays both regular and 4K statuses at the same time if needed. Added a comma to the end of the "RequestStatus" label to maintain design consistency with the other labels. Also added a "Denied Reason" element for 4K  requests.

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.22.3 [skip ci]

* chore: Storybook (#4700)

[skip ci]

* chore: Translations

[skip ci]

* 🌐 Translations Update (#4704)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci] (#4713)

* build: Run automation tests in docker (#4715)

[skip ci]

* fix: fixed trakt image not loading when base url present (#4711)

[skip ci]

* fix: 🐛 Fixed missing externals (#4712)

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.22.4 [skip ci]

* test: fixed automationt tests [skip ci]

* fix: Log Microsoft warnings to log file (#4723)

[skip ci]

* feat:  Recently Requested on Discover Page (#4387)

* chore(release): 🚀 v4.23.0 [skip ci]

* fix: Localize recently requested on discover page (#4729)

[skip ci]

* 🌐 Translations Update (#4731)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* Fix: Ombi.Api.Lidarr: Remove unused fields from ArtistAdd (#4727)

When an artist is not found in Lidarr as part of requesting an album,
MusicSender will make a POST request against the /api/v1/artist endpoint
to add such artist.

Not all fields defined in ArtistAdd are initialized, and those
uninitialized will be `null` values in the JSON body of the request, as
shown in this intercepted request:

```
{
    "addOptions": {
        "AlbumsToMonitor": [
            "e5c48b66-44ef-3685-ad53-45dbcd7294c0"
        ],
        "monitor": 6,
        "monitored": true,
        "searchForMissingAlbums": false
    },
    "added": "2022-08-10T06:49:32.4374278+00:00",
    "albumFolder": true,
    "artistName": "Manolo García",
    "cleanName": "manologarcía",
    "disambiguation": null,
    "discogsId": 0,
    "ended": false,
    "foreignArtistId": "1c8309da-9789-40bf-b9c2-e20064263820",
    "images": [],
    "links": [],
    "metadataProfileId": 1,
    "monitored": true,
    "overview": null,
    "qualityProfileId": 3,
    "ratings": null,
    "remotePoster": null,
    "rootFolderPath": "/media/music/",
    "sortName": null,
    "statistics": null,
    "status": null,
    "tadbId": 0,
    "tags": null
}
```

This request will fail and Lidarr will return a 400 BadRequest error
with the following message:

```
2022-08-10 01:45:52.458 +00:00 [Error] StatusCode: BadRequest, Reason: Bad Request, RequestUri: http://lidarr:8686/api/v1/artist
2022-08-10 01:45:52.459 +00:00 [Debug] {
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "00-52e01b322a05d7c6633eca2488ef2a5c-06345b3bb8c4bb6c-00",
  "errors": {
    "$.status": [
      "The JSON value could not be converted to NzbDrone.Core.Music.ArtistStatusType. Path: $.status | LineNumber: 0 | BytePositionInLine: 14."
    ]
  }
}
```

Removing all the `null` fields from the JSON body fixes the problem and
correctly adds the artist to Lidarr.

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.23.1 [skip ci]

* fix: Fix conflicting property name for Swagger (#4733)

* chore(release): 🚀 v4.23.2 [skip ci]

* feat: add crew on movie page (#4722)

* add crew on movie page

* order by director, add default image and fix click

Co-authored-by: tidusjar <tidusjar@gmail.com>

* 🌐 Translations Update (#4736)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* chore(release): 🚀 v4.24.0 [skip ci]

* feat: Watchlist history errors(#4741)

[skip ci]

* fix: fixed stats controller (#4742)

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.25.0 [skip ci]

* fix(webhook): Remove added trailing slash from webhook URL #4710

* chore(release): 🚀 v4.25.1 [skip ci]

* feat(notifications): Add more curly variables for partially available notification

* feat: Add more curly variables for partially available notification

* test: Fix newly added test

* chore(release): 🚀 v4.26.0 [skip ci]

* feat: Recently requested improvements (#4755)

* feat(discover):  Admins can now approve the Recently Requested list

* feat(discover):  Images for the recently requested area are now loading faster and just better all around

* test:  Added automation for the new feature

* chore(release): 🚀 v4.27.0 [skip ci]

* fix(plex): stop the plex sync from deleting episodes when we can't find the plex key

* chore(release): 🚀 v4.27.1 [skip ci]

* refactor: Encapsulate common TV availability checker logic (#4753)

[skip ci]

* fix(sonarr): 🐛 Cleaned up and removed Sonarr v3 option, sonarr v3 is now the default. This allows us to get ready for the upcoming Sonarr v4 (#4764)

* chore(release): 🚀 v4.27.2 [skip ci]

* 🌐 Translations Update (#4739)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(availability): 🐛 Fixed a issue with the availability checker after the previous update. Added full test coverage around that area

* chore(release): 🚀 v4.27.3 [skip ci]

* test: 🧪 added full test coverage to the plex availability checker, also fixed a small few bugs in there at the same time

* chore(release): 🚀 v4.27.4 [skip ci]

* fix(importer): 🐛 Allow you to only import Plex Admins without the Plex Users

* chore(release): 🚀 v4.27.5 [skip ci]

* fix(notifications): Fixed the error when sending multiple test notifications. Added more logging when Discord complains the message is invalid

* chore(release): 🚀 v4.27.6 [skip ci]

* fix: Fixes default image for recently requested items. (#4767)

* chore(release): 🚀 v4.27.7 [skip ci]

* refactor: Upgrades nuget packages. Removes deprecated packages. Fixes build warnings. (#4769)

* Upgrades nuget packages. Removes deprecated packages. Fixes build warnings.

* Fixes the last few build warnings.

* chore(release): 🚀 v4.27.8 [skip ci]

* refactor: Rework the Plex Settings Page (#4772)

[skip ci]

* feat(plex):  Added the ability to configure the watchlist to request the whole TV show rather than latest season (#4774)

* chore(release): 🚀 v4.28.0 [skip ci]

* 🌐 Translations Update (#4771)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* 🌐 Translations Update (#4775)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix: Reworked the version check (#4719) (#4781)

[skip ci]

* fix(plex): 🐛 Fixed not being able to enable watchlist requests in the Plex settings

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.28.1 [skip ci]

* feat: Provide a flag for missing users on Plex Server (#4688) (#4778)

[skip ci]

* fix: Unable to Delete Jellyfin Server (#4705) (#4780)

[skip ci]

* fix: Partially Available prevents further TV requests (#4768) (#4779)

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.29.0 [skip ci]

* fix: Consistently reset loading flag when requesting movies on discover page. (#4777)

[skip ci]

* fix(sonarr): 🐛 Fixed an issue where the language list didn't correctly load for power users in the advanced options #4782

* chore(release): 🚀 v4.29.1 [skip ci]

* fix(plex): Fixed an issue where sometimes the availability checker would throw an exception when checking episodes

* chore: fixed tests

* chore(release): 🚀 v4.29.2 [skip ci]

* fix: Only log error messages from Microsoft (#4787)

[skip ci]

* 🌐 Translations Update (#4784)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(notifications): Fixed the Partially TV notifications going to the admin #4797 (#4799)

* chore(release): 🚀 v4.29.3 [skip ci]

* feat(sonarr):  Add the username to a Sonarr tag when sent to Sonarr (#4802)

* chore(release): 🚀 v4.30.0 [skip ci]

* feat(sonarr): Added the ability to add default tags when sending to Sonarr (#4803)

* chore(release): 🚀 v4.31.0 [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci] (#4801)

* feat(plex): Rework the Plex Settings page (#4805)

* chore(release): 🚀 v4.32.0 [skip ci]

* fix(plex): 🐛 Fixed the issue where you couldn't add a new server on a fresh setup after the settings page rework

* chore(release): 🚀 v4.32.1 [skip ci]

* fix(sonarr): 🐛 Sonarr V4 should work now (#4810)

* fix(sonarr): 🐛 Sonarr V4 should work now

Auto detect the sonarr version and adjust the UI depending on V3 or V4 (Lang profiles)

* fix: Fixed the load error

* chore(release): 🚀 v4.32.2 [skip ci]

* fix(sonarr): V4 actually works this time around

* chore(release): 🚀 v4.32.3 [skip ci]

* feat: Angular 15 and Dependency upgrades (#4818)

* chore(release): 🚀 v4.33.0 [skip ci]

* fix(plex): Added the watchlist request whole show back into the settings

* chore: undid

* fixed (#4833)

* chore(release): 🚀 v4.33.1 [skip ci]

* chore: add logo [skip ci]

* feat: Radarr tags (#4815)

* chore(release): 🚀 v4.34.0 [skip ci]

* fix(plex-watchlist): Lookup the ID from different sources when Plex doesn't contain the metadata (#4843)

* chore(release): 🚀 v4.34.1 [skip ci]

* feat: Add the option for header authentication to create users (#4841)

* feat: allow SSO to create new users automatically

* feat: apply default user settings to SSO users

* feat: add warnings to header auth toggles

* chore(release): 🚀 v4.35.0 [skip ci]

* fix(plex-watchlist): Index out of bounds error

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.35.1 [skip ci]

* fix(database): Just some tweaks, shouldn't notice any difference, maybe a less error in the log

* chore(release): 🚀 v4.35.2 [skip ci]

* fix(#4847): Invalid Discord request fixed, also fixed an issue where App Only users would not show as logged in on the user management page (#4848)

* chore(release): 🚀 v4.35.3 [skip ci]

* bug(#4854): 🐛 Fixed the Recently Requested showing requests when it should be hidden

* fix(discover): 🐛 Fixed the default poster not taking into account the base url in some scenarios #4845

* fix(Hide music from navbar and request list when not enabled): 🐛

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.35.4 [skip ci]

* fix(radarr-settings): 🐛 Fixed a typo

* chore(release): 🚀 v4.35.5 [skip ci]

* fix: Fixed the issue where the login page is still present after logging in with oauth

* chore(release): 🚀 v4.35.6 [skip ci]

* fix(wizard): 🐛 Stop access to the wizard when you have already setup ombi (#4866)

* chore(release): 🚀 v4.35.7 [skip ci]

* fix(plex-oauth): 🐛 Fixed an issue where using OAuth you could log in as a Ombi Local user #4835

* chore(release): 🚀 v4.35.8 [skip ci]

* chore: 👥 Updated Contributors [skip ci]

* fixed bad merge

* chore(release): 🚀 v4.35.9 [skip ci]

* Update .gitignore

* Fixed automation

---------

Signed-off-by: Jamie <tidusjar@gmail.com> [skip ci]
Co-authored-by: sephrat <34862846+sephrat@users.noreply.github.com>
Co-authored-by: Conventional Changelog Action <conventional.changelog.action@github.com>
Co-authored-by: Teifun2 <Teifun2@users.noreply.github.com>
Co-authored-by: contrib-readme-bot <contrib-readme-action@noreply.com>
Co-authored-by: dr3amer <91037083+dr3am37@users.noreply.github.com>
Co-authored-by: echel0n <echel0n@sickrage.ca>
Co-authored-by: Marley <55280588+marleypowell@users.noreply.github.com>
Co-authored-by: Igor Borges <igor@borges.dev>
Co-authored-by: Lucane <Lucane@users.noreply.github.com>
Co-authored-by: mkgeeky <github@mkgeeky.xyz>
Co-authored-by: Miguel A Vico Moya <mvicomoya@gmail.com>
Co-authored-by: Hadrien <26697460+ketsapiwiq@users.noreply.github.com>
Co-authored-by: Victor Usoltsev <bernarden@users.noreply.github.com>
Co-authored-by: Wesley King <kingwe92@gmail.com>
Co-authored-by: Lea <me@janderedev.xyz>

* chore(release): 🚀 v4.35.10 [skip ci]

---------

Signed-off-by: Jamie <tidusjar@gmail.com> [skip ci]
Co-authored-by: sephrat <34862846+sephrat@users.noreply.github.com>
Co-authored-by: Conventional Changelog Action <conventional.changelog.action@github.com>
Co-authored-by: Teifun2 <Teifun2@users.noreply.github.com>
Co-authored-by: contrib-readme-bot <contrib-readme-action@noreply.com>
Co-authored-by: dr3amer <91037083+dr3am37@users.noreply.github.com>
Co-authored-by: echel0n <echel0n@sickrage.ca>
Co-authored-by: Marley <55280588+marleypowell@users.noreply.github.com>
Co-authored-by: Igor Borges <igor@borges.dev>
Co-authored-by: Lucane <Lucane@users.noreply.github.com>
Co-authored-by: mkgeeky <github@mkgeeky.xyz>
Co-authored-by: Miguel A Vico Moya <mvicomoya@gmail.com>
Co-authored-by: Hadrien <26697460+ketsapiwiq@users.noreply.github.com>
Co-authored-by: Victor Usoltsev <bernarden@users.noreply.github.com>
Co-authored-by: Wesley King <kingwe92@gmail.com>
Co-authored-by: Lea <me@janderedev.xyz>

* Develop master (#4931) [skip ci]

* Update (#4871)

* Remove dead code

* Localize TV requests messages on TV details page

* Transform buttons with link into anchors

* Sonarr sync: stop using seasonpass API

* chore(release): 🚀 v4.16.13

* Fix requests when 4k available and 4k disabled

Fixes #4610

* chore(release): 🚀 v4.16.14

* Hide subscribe button when request is available

* Hide subscribe button when request is denied

* Add Title to Partially Available Message

If the Title of the show is not menitoned it can be unclear what Episodes are now available.

* Better error message when test email fails due to missing recipient

* feat(discover): Add original language filter

* chore(release): 🚀 v4.16.15

* fix(4616): 🐛 fixed mandatory fields

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.16.16

* added test results into the PR pipeline

* chore(release): 🚀 v4.16.17

* Add information about cache refresh

* Update pr.yml

[skip ci]

* Update pr.yml

[skip ci]

* Update pr.yml

[skip ci]

* chore(release): 🚀 v4.17.0

* feat(discover): Add new trending source experimental feature

* fix(settings): Allow toggling features when there are more than one

* fix(discover): Fix new trending feature detection

* fix(discover): Fix cache mix up

* refactor(discover): Move movie trending feature toggle to backend

* feat(discover): Default trending source to new logic

* chore(release): 🚀 v4.18.0

* feat(sync): Detect reidentified movies in Emby and Jellyfin

* feat(sync): Detect reidentified series in Emby and Jellyfin

* Fix sync log criticity

* Update pr.yml

[skip ci]

* Update label.yml

[skip ci]

* Fix formatting

* Update pr.yml

[skip ci]

* Update label.yml

[skip ci]

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.19.0

* refactor(newsletter): Clarify very rare cases where newsletter doesn't publish a series

* refactor(newsletter): Clarify very rare cases where newsletter doesn't publish movie

* chore(release): 🚀 v4.19.1

* feat(discover): Show more relevant shows in upcoming TV

* chore(release): 🚀 v4.20.0

* fix(sync): Emby+Jellyfin - sync multi-episode files of 3+ episodes

* perf(sync): Emby+Jellyfin - use a more reliable filter to missing items

* fix(sync): Emby+Jellyfin - sync multi-episode files of 3+ episodes [skip ci]

* fix: added media type tag to media type text (#4638)

[skip ci]

* fix(sickrage): Fixed issue with incorrect handling of SiCKRAGE episode results returned during episode status changes, now expects array of objects from data path if present (#4648)

[skip ci]

* fix: Missing Poster broken link fix (#4637)

[skip ci]

* 🌐 Translations Update (#4622)

[skip ci]

* Update launch.json (#4650)

[skip ci]

* fix: Improve Swagger documentation (#4652)

* Upgrade Swashbuckle dependency

* Document /token response

* Add support for Newtonsoft annotations in Swagger

* Remove unecessary ActionResult [skip ci]

* fix(API): Fix pagination in some edge cases (#4649)

[skip ci]

* 🌐 Translations Update (#4655)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(discover): Carousel touch not working when scrolling page and recommendations and similar movie navigation (#4633)

* fixed touch not working on carousels

* fixed touch not working

* Movie details component fixes

Fixed recommendations and similar not changing the data on the component by calling the init function again on param change

Moved the ngif results > 0 to the mat-expansion panel to avoid rendering  the entire element if it doesn't have any results instead of having an empty panel.

* removed unused line, added scroll to top on init

* updated recommendation refresh implementation

Changed the implementation to use the router instead in order to reload the component instead of just reloading the data.

This implementation makes sure the component gets destroyed on navigation eliminating any memory leaks, reloading CSS in case of having animations on page load and generally a continuation of the experience you get when you browse into a movie from the discover page.

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.20.1 [skip ci]

* fix: 🐛 Fixed the Request on Behalf of having blanks (#4667)

* chore(release): 🚀 v4.20.2 [skip ci]

* fix(plex): 🐛 Fixed an issue with the Plex Sync

* chore(release): 🚀 v4.20.3 [skip ci]

* fix (technical): Improved some of the date time parsing handling

* fix: fixed build

* chore(release): 🚀 v4.20.4 [skip ci]

* feat: Upgrade to Angular14 (#4668)

* refactor: 🔥 removed angular-bootstrap-md dependancy

* chore: update tsconfig

* yeah

* ng14 upgrade

* refactor: migration changes

* fix: fixed CLI

* test: Fixed automation

* chore: 👥 Updated Contributors [skip ci]

* perf: stop populating obsolete subscribe fields (#4625)

* chore(release): 🚀 v4.21.0 [skip ci]

* fix(images): Retry images with a backoff when we get a Too Many requests from TheMovieDb #4685

* chore(release): 🚀 v4.21.1 [skip ci]

* 🌐 Translations Update (#4683)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix: Landing and Login page improvements (#4690)

* chore(release): 🚀 v4.21.2 [skip ci]

* feat(discover):  Added infinite scroll on advanced search results

* feat(discover):  Added infinite scroll on advanced search results

* chore(release): 🚀 v4.22.0 [skip ci]

* 🌐 Translations Update (#4694)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(discover): 🐛 Created new Image component to handle 429's from TMDB (#4698) and fixed #4635 (#4699)

* chore(release): 🚀 v4.22.1 [skip ci]

* fix: fixed an issue where I broke images for some users

* chore(release): 🚀 v4.22.2 [skip ci]

* ci(Mergify): configuration update (#4701)

Signed-off-by: Jamie <tidusjar@gmail.com> [skip ci]

* fix: Override Sonarr V3 Profiles endpoint (#4678)

* Override Sonarr V3 Profiles endpoint [skip ci]

* fix(4K) :4K request fixes (#4702)

* GetRequestsByStatus wasn't implementing the MovieRequests object correctly for 4K quality requests with the ProcessingRequest status.

* Fixed 4K requests not getting automatically approved if the user has the "Auto Approve Movie" role flag enabled.

* Fixed "Request Date" values for the "left-panel-details" div class. Previously when the movie was exclusively 4K (regular request was absent), then "Request Date" equaled DateTime.MinValue (January 1, 0001).

* Fixed "Request Status" evaluation in the "left-panel-details" div class. Now it shows the appropriate status instead of an empty spot. "Request Status" displays both regular and 4K statuses at the same time if needed. Added a comma to the end of the "RequestStatus" label to maintain design consistency with the other labels. Also added a "Denied Reason" element for 4K  requests.

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.22.3 [skip ci]

* chore: Storybook (#4700)

[skip ci]

* chore: Translations

[skip ci]

* 🌐 Translations Update (#4704)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci] (#4713)

* build: Run automation tests in docker (#4715)

[skip ci]

* fix: fixed trakt image not loading when base url present (#4711)

[skip ci]

* fix: 🐛 Fixed missing externals (#4712)

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.22.4 [skip ci]

* test: fixed automationt tests [skip ci]

* fix: Log Microsoft warnings to log file (#4723)

[skip ci]

* feat:  Recently Requested on Discover Page (#4387)

* chore(release): 🚀 v4.23.0 [skip ci]

* fix: Localize recently requested on discover page (#4729)

[skip ci]

* 🌐 Translations Update (#4731)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* Fix: Ombi.Api.Lidarr: Remove unused fields from ArtistAdd (#4727)

When an artist is not found in Lidarr as part of requesting an album,
MusicSender will make a POST request against the /api/v1/artist endpoint
to add such artist.

Not all fields defined in ArtistAdd are initialized, and those
uninitialized will be `null` values in the JSON body of the request, as
shown in this intercepted request:

```
{
    "addOptions": {
        "AlbumsToMonitor": [
            "e5c48b66-44ef-3685-ad53-45dbcd7294c0"
        ],
        "monitor": 6,
        "monitored": true,
        "searchForMissingAlbums": false
    },
    "added": "2022-08-10T06:49:32.4374278+00:00",
    "albumFolder": true,
    "artistName": "Manolo García",
    "cleanName": "manologarcía",
    "disambiguation": null,
    "discogsId": 0,
    "ended": false,
    "foreignArtistId": "1c8309da-9789-40bf-b9c2-e20064263820",
    "images": [],
    "links": [],
    "metadataProfileId": 1,
    "monitored": true,
    "overview": null,
    "qualityProfileId": 3,
    "ratings": null,
    "remotePoster": null,
    "rootFolderPath": "/media/music/",
    "sortName": null,
    "statistics": null,
    "status": null,
    "tadbId": 0,
    "tags": null
}
```

This request will fail and Lidarr will return a 400 BadRequest error
with the following message:

```
2022-08-10 01:45:52.458 +00:00 [Error] StatusCode: BadRequest, Reason: Bad Request, RequestUri: http://lidarr:8686/api/v1/artist
2022-08-10 01:45:52.459 +00:00 [Debug] {
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "00-52e01b322a05d7c6633eca2488ef2a5c-06345b3bb8c4bb6c-00",
  "errors": {
    "$.status": [
      "The JSON value could not be converted to NzbDrone.Core.Music.ArtistStatusType. Path: $.status | LineNumber: 0 | BytePositionInLine: 14."
    ]
  }
}
```

Removing all the `null` fields from the JSON body fixes the problem and
correctly adds the artist to Lidarr.

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.23.1 [skip ci]

* fix: Fix conflicting property name for Swagger (#4733)

* chore(release): 🚀 v4.23.2 [skip ci]

* feat: add crew on movie page (#4722)

* add crew on movie page

* order by director, add default image and fix click

Co-authored-by: tidusjar <tidusjar@gmail.com>

* 🌐 Translations Update (#4736)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* chore(release): 🚀 v4.24.0 [skip ci]

* feat: Watchlist history errors(#4741)

[skip ci]

* fix: fixed stats controller (#4742)

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.25.0 [skip ci]

* fix(webhook): Remove added trailing slash from webhook URL #4710

* chore(release): 🚀 v4.25.1 [skip ci]

* feat(notifications): Add more curly variables for partially available notification

* feat: Add more curly variables for partially available notification

* test: Fix newly added test

* chore(release): 🚀 v4.26.0 [skip ci]

* feat: Recently requested improvements (#4755)

* feat(discover):  Admins can now approve the Recently Requested list

* feat(discover):  Images for the recently requested area are now loading faster and just better all around

* test:  Added automation for the new feature

* chore(release): 🚀 v4.27.0 [skip ci]

* fix(plex): stop the plex sync from deleting episodes when we can't find the plex key

* chore(release): 🚀 v4.27.1 [skip ci]

* refactor: Encapsulate common TV availability checker logic (#4753)

[skip ci]

* fix(sonarr): 🐛 Cleaned up and removed Sonarr v3 option, sonarr v3 is now the default. This allows us to get ready for the upcoming Sonarr v4 (#4764)

* chore(release): 🚀 v4.27.2 [skip ci]

* 🌐 Translations Update (#4739)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(availability): 🐛 Fixed a issue with the availability checker after the previous update. Added full test coverage around that area

* chore(release): 🚀 v4.27.3 [skip ci]

* test: 🧪 added full test coverage to the plex availability checker, also fixed a small few bugs in there at the same time

* chore(release): 🚀 v4.27.4 [skip ci]

* fix(importer): 🐛 Allow you to only import Plex Admins without the Plex Users

* chore(release): 🚀 v4.27.5 [skip ci]

* fix(notifications): Fixed the error when sending multiple test notifications. Added more logging when Discord complains the message is invalid

* chore(release): 🚀 v4.27.6 [skip ci]

* fix: Fixes default image for recently requested items. (#4767)

* chore(release): 🚀 v4.27.7 [skip ci]

* refactor: Upgrades nuget packages. Removes deprecated packages. Fixes build warnings. (#4769)

* Upgrades nuget packages. Removes deprecated packages. Fixes build warnings.

* Fixes the last few build warnings.

* chore(release): 🚀 v4.27.8 [skip ci]

* refactor: Rework the Plex Settings Page (#4772)

[skip ci]

* feat(plex):  Added the ability to configure the watchlist to request the whole TV show rather than latest season (#4774)

* chore(release): 🚀 v4.28.0 [skip ci]

* 🌐 Translations Update (#4771)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* 🌐 Translations Update (#4775)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix: Reworked the version check (#4719) (#4781)

[skip ci]

* fix(plex): 🐛 Fixed not being able to enable watchlist requests in the Plex settings

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.28.1 [skip ci]

* feat: Provide a flag for missing users on Plex Server (#4688) (#4778)

[skip ci]

* fix: Unable to Delete Jellyfin Server (#4705) (#4780)

[skip ci]

* fix: Partially Available prevents further TV requests (#4768) (#4779)

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.29.0 [skip ci]

* fix: Consistently reset loading flag when requesting movies on discover page. (#4777)

[skip ci]

* fix(sonarr): 🐛 Fixed an issue where the language list didn't correctly load for power users in the advanced options #4782

* chore(release): 🚀 v4.29.1 [skip ci]

* fix(plex): Fixed an issue where sometimes the availability checker would throw an exception when checking episodes

* chore: fixed tests

* chore(release): 🚀 v4.29.2 [skip ci]

* fix: Only log error messages from Microsoft (#4787)

[skip ci]

* 🌐 Translations Update (#4784)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(notifications): Fixed the Partially TV notifications going to the admin #4797 (#4799)

* chore(release): 🚀 v4.29.3 [skip ci]

* feat(sonarr):  Add the username to a Sonarr tag when sent to Sonarr (#4802)

* chore(release): 🚀 v4.30.0 [skip ci]

* feat(sonarr): Added the ability to add default tags when sending to Sonarr (#4803)

* chore(release): 🚀 v4.31.0 [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci] (#4801)

* feat(plex): Rework the Plex Settings page (#4805)

* chore(release): 🚀 v4.32.0 [skip ci]

* fix(plex): 🐛 Fixed the issue where you couldn't add a new server on a fresh setup after the settings page rework

* chore(release): 🚀 v4.32.1 [skip ci]

* fix(sonarr): 🐛 Sonarr V4 should work now (#4810)

* fix(sonarr): 🐛 Sonarr V4 should work now

Auto detect the sonarr version and adjust the UI depending on V3 or V4 (Lang profiles)

* fix: Fixed the load error

* chore(release): 🚀 v4.32.2 [skip ci]

* fix(sonarr): V4 actually works this time around

* chore(release): 🚀 v4.32.3 [skip ci]

* feat: Angular 15 and Dependency upgrades (#4818)

* chore(release): 🚀 v4.33.0 [skip ci]

* fix(plex): Added the watchlist request whole show back into the settings

* chore: undid

* fixed (#4833)

* chore(release): 🚀 v4.33.1 [skip ci]

* chore: add logo [skip ci]

* feat: Radarr tags (#4815)

* chore(release): 🚀 v4.34.0 [skip ci]

* fix(plex-watchlist): Lookup the ID from different sources when Plex doesn't contain the metadata (#4843)

* chore(release): 🚀 v4.34.1 [skip ci]

* feat: Add the option for header authentication to create users (#4841)

* feat: allow SSO to create new users automatically

* feat: apply default user settings to SSO users

* feat: add warnings to header auth toggles

* chore(release): 🚀 v4.35.0 [skip ci]

* fix(plex-watchlist): Index out of bounds error

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.35.1 [skip ci]

* fix(database): Just some tweaks, shouldn't notice any difference, maybe a less error in the log

* chore(release): 🚀 v4.35.2 [skip ci]

* fix(#4847): Invalid Discord request fixed, also fixed an issue where App Only users would not show as logged in on the user management page (#4848)

* chore(release): 🚀 v4.35.3 [skip ci]

* bug(#4854): 🐛 Fixed the Recently Requested showing requests when it should be hidden

* fix(discover): 🐛 Fixed the default poster not taking into account the base url in some scenarios #4845

* fix(Hide music from navbar and request list when not enabled): 🐛

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.35.4 [skip ci]

* fix(radarr-settings): 🐛 Fixed a typo

* chore(release): 🚀 v4.35.5 [skip ci]

* fix: Fixed the issue where the login page is still present after logging in with oauth

* chore(release): 🚀 v4.35.6 [skip ci]

* fix(wizard): 🐛 Stop access to the wizard when you have already setup ombi (#4866)

* chore(release): 🚀 v4.35.7 [skip ci]

* fix(plex-oauth): 🐛 Fixed an issue where using OAuth you could log in as a Ombi Local user #4835

* chore(release): 🚀 v4.35.8 [skip ci]

* chore: 👥 Updated Contributors [skip ci]

* fixed bad merge

* chore(release): 🚀 v4.35.9 [skip ci]

* Update .gitignore

* Fixed automation

---------

Signed-off-by: Jamie <tidusjar@gmail.com> [skip ci]
Co-authored-by: sephrat <34862846+sephrat@users.noreply.github.com>
Co-authored-by: Conventional Changelog Action <conventional.changelog.action@github.com>
Co-authored-by: Teifun2 <Teifun2@users.noreply.github.com>
Co-authored-by: contrib-readme-bot <contrib-readme-action@noreply.com>
Co-authored-by: dr3amer <91037083+dr3am37@users.noreply.github.com>
Co-authored-by: echel0n <echel0n@sickrage.ca>
Co-authored-by: Marley <55280588+marleypowell@users.noreply.github.com>
Co-authored-by: Igor Borges <igor@borges.dev>
Co-authored-by: Lucane <Lucane@users.noreply.github.com>
Co-authored-by: mkgeeky <github@mkgeeky.xyz>
Co-authored-by: Miguel A Vico Moya <mvicomoya@gmail.com>
Co-authored-by: Hadrien <26697460+ketsapiwiq@users.noreply.github.com>
Co-authored-by: Victor Usoltsev <bernarden@users.noreply.github.com>
Co-authored-by: Wesley King <kingwe92@gmail.com>
Co-authored-by: Lea <me@janderedev.xyz>

* chore(release): 🚀 v4.35.10 [skip ci]

---------

Signed-off-by: Jamie <tidusjar@gmail.com> [skip ci]
Co-authored-by: sephrat <34862846+sephrat@users.noreply.github.com>
Co-authored-by: Conventional Changelog Action <conventional.changelog.action@github.com>
Co-authored-by: Teifun2 <Teifun2@users.noreply.github.com>
Co-authored-by: contrib-readme-bot <contrib-readme-action@noreply.com>
Co-authored-by: dr3amer <91037083+dr3am37@users.noreply.github.com>
Co-authored-by: echel0n <echel0n@sickrage.ca>
Co-authored-by: Marley <55280588+marleypowell@users.noreply.github.com>
Co-authored-by: Igor Borges <igor@borges.dev>
Co-authored-by: Lucane <Lucane@users.noreply.github.com>
Co-authored-by: mkgeeky <github@mkgeeky.xyz>
Co-authored-by: Miguel A Vico Moya <mvicomoya@gmail.com>
Co-authored-by: Hadrien <26697460+ketsapiwiq@users.noreply.github.com>
Co-authored-by: Victor Usoltsev <bernarden@users.noreply.github.com>
Co-authored-by: Wesley King <kingwe92@gmail.com>
Co-authored-by: Lea <me@janderedev.xyz>

* fix(emby): Fix Emby played sync running a full sync during recently added sync (#4932)

* feat: Hide watched status when request is not available (#4934)

* chore(release): 🚀 v4.39.0 [skip ci]

* chore(release): 🚀 v4.40.0 [skip ci]

* feat(emby): Show end-user external IP address to Emby when logging in as an Emby user (#4949)

Fixes #4947

* chore: 👥 Updated Contributors [skip ci]

* fix: Fix various styling issues (#4935)

* chore(release): 🚀 v4.41.0 [skip ci]

* chore(release): 🚀 v4.41.1 [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci] (#4926)

* feat(emby): Show watched status for TV requests

* feat(emby): Show watched status for TV requests

* Consider only requested episodes in  played progress

* Clarify tv watched progress tooltip

* Fix unrespected code guidelines

* chore(release): 🚀 v4.42.0 [skip ci]

* 🌐 Translations Update (#4952)

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* fix(translations): 🌐 New translations from Crowdin [skip ci]

* chore: 🔥 Remove unused dependency (#4959) [skip ci]

* chore: 🔥 Remove unused dependency

* chore: 🔥 Remove Angular Localize

* Remove unused dep (#4960) [skip ci]

* chore: 🔥 Remove unused dependency

* chore: 🔥 Remove Angular Localize

* deps update

* fix: upgrade @microsoft/signalr from 6.0.11 to 6.0.16 (#4964) [skip ci]

Snyk has created this PR to upgrade @microsoft/signalr from 6.0.11 to 6.0.16.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/ombi-shared/project/7a4dc3b5-498d-41a0-82fb-9781f30ae243?utm_source=github&utm_medium=referral&page=upgrade-pr

Co-authored-by: snyk-bot <snyk-bot@snyk.io>

* fix: upgrade primeng from 15.0.0-rc.1 to 15.4.1 (#4962) [skip ci]

Snyk has created this PR to upgrade primeng from 15.0.0-rc.1 to 15.4.1.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/ombi-shared/project/7a4dc3b5-498d-41a0-82fb-9781f30ae243?utm_source=github&utm_medium=referral&page=upgrade-pr

Co-authored-by: snyk-bot <snyk-bot@snyk.io>

* fix: src/Ombi.Notifications/Ombi.Notifications.csproj to reduce vulnerabilities (#4969) [skip ci]

The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-DOTNET-SYSTEMSECURITYCRYPTOGRAPHYPKCS-5708426

Co-authored-by: snyk-bot <snyk-bot@snyk.io>

* fix: upgrade @fortawesome/fontawesome-free from 6.1.2 to 6.4.0 (#4965) [skip ci]

Snyk has created this PR to upgrade @fortawesome/fontawesome-free from 6.1.2 to 6.4.0.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/ombi-shared/project/7a4dc3b5-498d-41a0-82fb-9781f30ae243?utm_source=github&utm_medium=referral&page=upgrade-pr

Co-authored-by: snyk-bot <snyk-bot@snyk.io>

* fix: upgrade multiple dependencies with Snyk (#4963) [skip ci]

Snyk has created this PR to upgrade:
  - @angular/animations from 15.2.4 to 15.2.9.
    See this package in npm: 
  - @angular/common from 15.2.4 to 15.2.9.
    See this package in npm: 
  - @angular/compiler from 15.2.4 to 15.2.9.
    See this package in npm: 
  - @angular/core from 15.2.4 to 15.2.9.
    See this package in npm: 
  - @angular/forms from 15.2.4 to 15.2.9.
    See this package in npm: 
  - @angular/platform-browser from 15.2.4 to 15.2.9.
    See this package in npm: 
  - @angular/platform-browser-dynamic from 15.2.4 to 15.2.9.
    See this package in npm: 
  - @angular/platform-server from 15.2.4 to 15.2.9.
    See this package in npm: 
  - @angular/router from 15.2.4 to 15.2.9.
    See this package in npm: 

See this project in Snyk:
https://app.snyk.io/org/ombi-shared/project/7a4dc3b5-498d-41a0-82fb-9781f30ae243?utm_source=github&utm_medium=referral&page=upgrade-pr

Co-authored-by: snyk-bot <snyk-bot@snyk.io>

* small improvements to fix flaky tests (#4970) [skip ci]

* fix: More automation tests mainly around the Plex Settings page (#4821)

* updates

* test coverage on the plex settings page

* features

* Update cypress.yml

* Update cypress.yml

* Update cypress.yml

* Update cypress.config.ts

* fixes

* stuff

* put it back

* a

* always kill docker

* Run the wizard as part of the feature files

* fix?

* slow the tests down

* subby

* Update user-preferences-profile.spec.ts

* Update user-preferences-profile.spec.ts

* fix: upgrade cypress-real-events from 1.7.4 to 1.8.1 (#4968) [skip ci]

Snyk has created this PR to upgrade cypress-real-events from 1.7.4 to 1.8.1.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/ombi-shared/project/750c1ef4-1459-4f30-a181-009a5f1ea1dc?utm_source=github&utm_medium=referral&page=upgrade-pr

Co-authored-by: snyk-bot <snyk-bot@snyk.io>

* chore(release): 🚀 v4.42.1 [skip ci]

* Remove unused dependancy [skip ci]

* chore: updates (#4971) [skip ci]

* docs: Update README.md (#4972)

[skip ci]

* fix: Remove Angular TSLint (#4973)

* Update tslint.json

* Update package.json

[skip ci]

* fix: upgrade zone.js from 0.11.8 to 0.13.0 (#4975)

[skip ci]

Snyk has created this PR to upgrade zone.js from 0.11.8 to 0.13.0.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/ombi-shared/project/7a4dc3b5-498d-41a0-82fb-9781f30ae243?utm_source=github&utm_medium=referral&page=upgrade-pr

Co-authored-by: snyk-bot <snyk-bot@snyk.io>

* fix: upgrade jquery from 3.6.1 to 3.7.0 (#4974)

[skip ci]

Snyk has created this PR to upgrade jquery from 3.6.1 to 3.7.0.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/ombi-shared/project/7a4dc3b5-498d-41a0-82fb-9781f30ae243?utm_source=github&utm_medium=referral&page=upgrade-pr

Co-authored-by: snyk-bot <snyk-bot@snyk.io>

* fix: upgrade multiple dependencies with Snyk (#4961)

Snyk has created this PR to upgrade:
  - @ngxs/devtools-plugin from 3.7.3 to 3.8.1.
    See this package in npm: 
  - @ngxs/store from 3.7.3 to 3.8.1.
    See this package in npm: 

See this project in Snyk:
https://app.snyk.io/org/ombi-shared/project/7a4dc3b5-498d-41a0-82fb-9781f30ae243?utm_source=github&utm_medium=referral&page=upgrade-pr

Co-authored-by: snyk-bot <snyk-bot@snyk.io>

* Small fixes (#4978)

* fix(tv-requests): 🐛 Fixed a small bug where an exception can get thrown when attempting to view TV Requests

* fix(user-importer): 🐛 Fixed an issue where the cleanup wouldn't delete users #4812

* chore: 👥 Updated Contributors [skip ci]

* chore(release): 🚀 v4.42.2 [skip ci]

* Bug improvements [skip ci]  (#4979)

* Update config.yml

* stuff

* Update bug_report.yml

* fix(user-importer): Do not delete the Plex Admin as part of the user Importer cleanup #4870 (#4981)

* chore(release): 🚀 v4.42.3 [skip ci]

* feat: Add Auto Approve 4K role (#4982) (#4983)

Legacy "Auto Approve" role now only applies to non-4K requests
Fixes #4957

Co-authored-by: sephrat <34862846+sephrat@users.noreply.github.com>

* chore(release): 🚀 v4.43.0 [skip ci]

* fix(user-importer): don't delete admins in the cleanup

* chore(release): 🚀 v4.43.1 [skip ci]

* fix: Remove old trending source (#4987)

[skip ci]

* fix(plex-api): Switch over to the new API to avoid deprecation & save… (#4986)

* fix(plex-api): Switch over to the new API to avoid deprecation & save the plex settings when modifying the servers

* Delete Ombi.sln

* chore(release): 🚀 v4.43.2 [skip ci]

* fix: switch back to the old plex friends API #4989

* chore(release): 🚀 v4.43.3 [skip ci]

* fix(user-importer): Fixed not importing all correct users #4989

* test: Add a unit test to cover the Unmanaged Home user scenario [skip ci]

* chore(release): 🚀 v4.43.4 [skip ci]

* fix: upgrade cypress-real-events from 1.8.1 to 1.9.1 (#5000) [skip ci]

Snyk has created this PR to upgrade cypress-real-events from 1.8.1 to 1.9.1.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/ombi-shared/project/750c1ef4-1459-4f30-a181-009a5f1ea1dc?utm_source=github&utm_medium=referral&page=upgrade-pr

Co-authored-by: snyk-bot <snyk-bot@snyk.io>

* fix: upgrade @microsoft/signalr from 6.0.18 to 6.0.20 (#4999) [skip ci]

Snyk has created this PR to upgrade @microsoft/signalr from 6.0.18 to 6.0.20.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/ombi-shared/project/7a4dc3b5-498d-41a0-82fb-9781f30ae243?utm_source=github&utm_medium=referral&page=upgrade-pr

Co-authored-by: snyk-bot <snyk-bot@snyk.io>

---------

Signed-off-by: Jamie <tidusjar@gmail.com> [skip ci]
Co-authored-by: Lucane <Lucane@users.noreply.github.com>
Co-authored-by: contrib-readme-bot <contrib-readme-action@noreply.com>
Co-authored-by: Conventional Changelog Action <conventional.changelog.action@github.com>
Co-authored-by: mkgeeky <github@mkgeeky.xyz>
Co-authored-by: sephrat <34862846+sephrat@users.noreply.github.com>
Co-authored-by: Miguel A Vico Moya <mvicomoya@gmail.com>
Co-authored-by: Hadrien <26697460+ketsapiwiq@users.noreply.github.com>
Co-authored-by: Victor Usoltsev <bernarden@users.noreply.github.com>
Co-authored-by: Wesley King <kingwe92@gmail.com>
Co-authored-by: Lea <me@janderedev.xyz>
Co-authored-by: ryan-c44 <54028283+ryan-c44@users.noreply.github.com>
Co-authored-by: Alexander Russell <ajex94@gmail.com>
Co-authored-by: Grygon <647846+Grygon@users.noreply.github.com>
Co-authored-by: phildups7 <60622768+phildups7@users.noreply.github.com>
Co-authored-by: Teifun2 <Teifun2@users.noreply.github.com>
Co-authored-by: dr3amer <91037083+dr3am37@users.noreply.github.com>
Co-authored-by: echel0n <echel0n@sickrage.ca>
Co-authored-by: Marley <55280588+marleypowell@users.noreply.github.com>
Co-authored-by: Igor Borges <igor@borges.dev>
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
pull/5143/head^2
Jamie 1 year ago committed by GitHub
parent c96b5a31df
commit dc410a5a3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,35 +0,0 @@
---
name: "\U0001F41B Bug report"
about: Create a report to help us improve
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Logs (Logs directory where Ombi is located)**
If applicable, a snippet of the logs that seems relevant to the bug if present.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
**Ombi Version (please complete the following information):**
- Version [e.g. 4.0.958]
- Media Server [e.g. Plex]
- Database Type: SQLite (Please change if using MySQL)
**Additional context**
Add any other context about the problem here.

@ -0,0 +1,50 @@
name: "\U0001F41B Bug report"
description: 'Report a reproducible bug in Ombi'
body:
- type: markdown
attributes:
value: Thanks for taking the time to file a bug report! Please fill out this form as completely as possible.
- type: markdown
attributes:
value: |
If you leave out sections there is a high likelihood your issue will be closed.
If you have a question or you think your issue might be caused by your application code, you can get help from the community on [Discord](https://discord.gg/Sa7wNWb).
- type: textarea
attributes:
label: Summary
description: |
Clearly describe what the expected behavior is vs. what is actually happening. Please include any reproduction steps that is required to reproduce this issue.
If your summary is simply, for example: "I cannot setup Plex", then you will need to [continue debugging on your own](https://docs.ombi.app/) to more precisely define your issue before proceeding.
validations:
required: true
- type: input
id: version
attributes:
label: Ombi Version
description: What version of ombi are you running?
validations:
required: true
- type: dropdown
attributes:
label: What platform(s) does this occur on?
multiple: true
options:
- Docker
- Windows
- Linux
validations:
required: true
- type: dropdown
attributes:
label: What database are you using?
options:
- SQLite (Default)
- MySQL
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell

@ -3,8 +3,8 @@ contact_links:
- name: Docs - name: Docs
url: https://docs.ombi.app/ url: https://docs.ombi.app/
about: The Ombi documentation should help guide you through installation and setup as well as help resolve common problems and answer frequently asked questions about: The Ombi documentation should help guide you through installation and setup as well as help resolve common problems and answer frequently asked questions
- name: Reddit support - name: Discord support
url: https://www.reddit.com/r/Ombi url: https://discord.gg/Sa7wNWb
about: Ask questions about Ombi about: Ask questions about Ombi
- name: Feature suggestions - name: Feature suggestions
url: https://ombifeatures.featureupvote.com url: https://ombifeatures.featureupvote.com

@ -43,8 +43,8 @@ jobs:
- name: Run Docker Image - name: Run Docker Image
run: nohup docker run --rm -p 5000:5000 ombi & run: nohup docker run --rm -p 5000:5000 ombi &
# - name: Run Wiremock Plex - name: Run Wiremock
# run: nohup docker run -it --rm -p 32400:8080 --name wiremock wiremock/wiremock:2.35.0 run: nohup docker run --rm -p 32400:8080 --name wiremock wiremock/wiremock:2.35.0 &
- name: Sleep for server to start - name: Sleep for server to start
run: sleep 20 run: sleep 20
@ -61,7 +61,7 @@ jobs:
# nohup dotnet run --project ./src/Ombi -- --host http://*:3577 & # nohup dotnet run --project ./src/Ombi -- --host http://*:3577 &
- name: Cypress Tests - name: Cypress Tests
uses: cypress-io/github-action@v2.8.2 uses: cypress-io/github-action@v4
with: with:
record: true record: true
browser: chrome browser: chrome
@ -73,3 +73,9 @@ jobs:
env: env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Stop Docker
if: always()
run: |
docker ps -q | xargs -I {} docker logs {}
docker container kill $(docker ps -q)

@ -106,7 +106,7 @@ jobs:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-dotnet@v1 - uses: actions/setup-dotnet@v1
with: with:
dotnet-version: '6.0.x' dotnet-version: '6.0.x'
- uses: actions/setup-dotnet@v1 - uses: actions/setup-dotnet@v1
with: with:
dotnet-version: '5.0.x' dotnet-version: '5.0.x'

@ -1,47 +1,47 @@
name: 'Chromatic' # name: 'Chromatic'
# Event for the workflow # # Event for the workflow
on: # on:
push: # push:
workflow_dispatch: # workflow_dispatch:
# List of jobs # # List of jobs
jobs: # jobs:
storybook-build: # storybook-build:
# Operating System # # Operating System
runs-on: ubuntu-latest # runs-on: ubuntu-latest
steps: # steps:
- name: Checkout repository # - name: Checkout repository
uses: actions/checkout@v2 # uses: actions/checkout@v2
with: # with:
fetch-depth: 0 # fetch-depth: 0
- name: NodeModules Cache # - name: NodeModules Cache
uses: actions/cache@v2 # uses: actions/cache@v2
with: # with:
path: '**/node_modules' # path: '**/node_modules'
key: node_modules-${{ hashFiles('**/yarn.lock') }} # key: node_modules-${{ hashFiles('**/yarn.lock') }}
- name: Install dependencies # - name: Install dependencies
working-directory: ./src/Ombi/ClientApp # working-directory: ./src/Ombi/ClientApp
run: yarn # run: yarn
- name: Publish to Chromatic # - name: Publish to Chromatic
if: github.ref != 'refs/heads/master' # if: github.ref != 'refs/heads/master'
uses: chromaui/action@v1 # uses: chromaui/action@v1
with: # with:
projectToken: 7c47e1a1a4bd # projectToken: 7c47e1a1a4bd
exitZeroOnChanges: true # exitZeroOnChanges: true
workingDir: ./src/Ombi/ClientApp # workingDir: ./src/Ombi/ClientApp
buildScriptName: storybookbuild # buildScriptName: storybookbuild
exitOnceUploaded: true # exitOnceUploaded: true
- name: Publish to Chromatic and auto accept changes # - name: Publish to Chromatic and auto accept changes
if: github.ref == 'refs/heads/develop' # if: github.ref == 'refs/heads/develop'
uses: chromaui/action@v1 # uses: chromaui/action@v1
with: # with:
projectToken: 7c47e1a1a4bd # projectToken: 7c47e1a1a4bd
autoAcceptChanges: true # 👈 Option to accept all changes # autoAcceptChanges: true # 👈 Option to accept all changes
workingDir: ./src/Ombi/ClientApp # workingDir: ./src/Ombi/ClientApp
buildScriptName: storybookbuild # buildScriptName: storybookbuild
exitOnceUploaded: true # exitOnceUploaded: true

@ -33,7 +33,7 @@ jobs:
unit-test: unit-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-dotnet@v1 - uses: actions/setup-dotnet@v1

File diff suppressed because it is too large Load Diff

@ -6,7 +6,7 @@ ____
[![Github All Releases](https://img.shields.io/github/downloads/tidusjar/Ombi/total.svg)](https://github.com/ombi-app/Ombi) [![Github All Releases](https://img.shields.io/github/downloads/tidusjar/Ombi/total.svg)](https://github.com/ombi-app/Ombi)
[![firsttimersonly](http://img.shields.io/badge/first--timers--only-friendly-blue.svg)](http://www.firsttimersonly.com/) [![firsttimersonly](http://img.shields.io/badge/first--timers--only-friendly-blue.svg)](http://www.firsttimersonly.com/)
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/ombi/localized.svg)](https://crowdin.com/project/ombi) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/ombi/localized.svg)](https://crowdin.com/project/ombi)
[![Automation Tests](https://github.com/Ombi-app/Ombi/actions/workflows/cypress.yml/badge.svg)](https://github.com/Ombi-app/Ombi/actions/workflows/cypress.yml) [![Automation Tests](https://github.com/Ombi-app/Ombi/actions/workflows/automation-tests.yml/badge.svg)](https://github.com/Ombi-app/Ombi/actions/workflows/automation-tests.yml)
[![Patreon](https://img.shields.io/badge/patreon-donate-yellow.svg)](https://patreon.com/tidusjar/Ombi) [![Patreon](https://img.shields.io/badge/patreon-donate-yellow.svg)](https://patreon.com/tidusjar/Ombi)
[![Paypal](https://img.shields.io/badge/paypal-donate-yellow.svg)](https://paypal.me/PlexRequestsNet) [![Paypal](https://img.shields.io/badge/paypal-donate-yellow.svg)](https://paypal.me/PlexRequestsNet)
@ -301,10 +301,10 @@ Here are some of the features Ombi has:
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/phildups7"> <a href="https://github.com/Fire-Swan">
<img src="https://avatars.githubusercontent.com/u/60622768?v=4" width="50;" alt="phildups7"/> <img src="https://avatars.githubusercontent.com/u/60622768?v=4" width="50;" alt="Fire-Swan"/>
<br /> <br />
<sub><b>Phildups7</b></sub> <sub><b>Fire-Swan</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
@ -616,8 +616,8 @@ Here are some of the features Ombi has:
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/janderedev"> <a href="https://github.com/sussycatgirl">
<img src="https://avatars.githubusercontent.com/u/26145882?v=4" width="50;" alt="janderedev"/> <img src="https://avatars.githubusercontent.com/u/26145882?v=4" width="50;" alt="sussycatgirl"/>
<br /> <br />
<sub><b>Lea</b></sub> <sub><b>Lea</b></sub>
</a> </a>

@ -56,7 +56,7 @@ namespace Ombi.Api.Emby
return obj; return obj;
} }
public async Task<EmbyUser> LogIn(string username, string password, string apiKey, string baseUri) public async Task<EmbyUser> LogIn(string username, string password, string apiKey, string baseUri, string clientIpAddress)
{ {
var request = new Request("emby/users/authenticatebyname", baseUri, HttpMethod.Post); var request = new Request("emby/users/authenticatebyname", baseUri, HttpMethod.Post);
var body = new var body = new
@ -71,6 +71,11 @@ namespace Ombi.Api.Emby
$"MediaBrowser Client=\"Ombi\", Device=\"Ombi\", DeviceId=\"v3\", Version=\"v3\""); $"MediaBrowser Client=\"Ombi\", Device=\"Ombi\", DeviceId=\"v3\", Version=\"v3\"");
AddHeaders(request, apiKey); AddHeaders(request, apiKey);
if (!string.IsNullOrEmpty(clientIpAddress))
{
request.AddHeader("X-Forwarded-For", clientIpAddress);
}
var obj = await Api.Request<EmbyUser>(request); var obj = await Api.Request<EmbyUser>(request);
return obj; return obj;
} }
@ -249,18 +254,30 @@ namespace Ombi.Api.Emby
req.AddHeader("Device", "Ombi"); req.AddHeader("Device", "Ombi");
} }
public async Task<EmbyItemContainer<EmbyMovie>> GetMoviesPlayed(string apiKey, string parentIdFilder, int startIndex, int count, string userId, string baseUri) public async Task<EmbyItemContainer<EmbyMovie>> GetMoviesPlayed(string apiKey, string parentIdFilder, int startIndex, int count, string userId, string baseUri) =>
{ await GetPlayed<EmbyMovie>("Movie", apiKey, userId, baseUri, startIndex, count, parentIdFilder, "ProviderIds");
return await GetPlayed<EmbyMovie>("Movie", apiKey, userId, baseUri, startIndex, count, parentIdFilder);
} public async Task<EmbyItemContainer<EmbyEpisodes>> GetTvPlayed(string apiKey, string parentIdFilder, int startIndex, int count, string userId, string baseUri) =>
await GetPlayed<EmbyEpisodes>("Episode", apiKey, userId, baseUri, startIndex, count, parentIdFilder);
private async Task<EmbyItemContainer<T>> GetPlayed<T>(string type, string apiKey, string userId, string baseUri, int startIndex, int count, string parentIdFilder = default)
private async Task<EmbyItemContainer<T>> GetPlayed<T>(
string type,
string apiKey,
string userId,
string baseUri,
int startIndex,
int count,
string parentIdFilder = default,
string fields = default)
{ {
var request = new Request($"emby/items", baseUri, HttpMethod.Get); var request = new Request($"emby/items", baseUri, HttpMethod.Get);
request.AddQueryString("Recursive", true.ToString()); request.AddQueryString("Recursive", true.ToString());
request.AddQueryString("IncludeItemTypes", type); request.AddQueryString("IncludeItemTypes", type);
request.AddQueryString("Fields", "ProviderIds"); if (!string.IsNullOrEmpty(fields))
{
request.AddQueryString("Fields", fields);
}
request.AddQueryString("UserId", userId); request.AddQueryString("UserId", userId);
request.AddQueryString("isPlayed", true.ToString()); request.AddQueryString("isPlayed", true.ToString());

@ -11,7 +11,7 @@ namespace Ombi.Api.Emby
{ {
Task<EmbySystemInfo> GetSystemInformation(string apiKey, string baseUrl); Task<EmbySystemInfo> GetSystemInformation(string apiKey, string baseUrl);
Task<List<EmbyUser>> GetUsers(string baseUri, string apiKey); Task<List<EmbyUser>> GetUsers(string baseUri, string apiKey);
Task<EmbyUser> LogIn(string username, string password, string apiKey, string baseUri); Task<EmbyUser> LogIn(string username, string password, string apiKey, string baseUri, string clientIpAddress);
Task<EmbyItemContainer<EmbyMovie>> GetAllMovies(string apiKey, string parentIdFilder, int startIndex, int count, string userId, Task<EmbyItemContainer<EmbyMovie>> GetAllMovies(string apiKey, string parentIdFilder, int startIndex, int count, string userId,
string baseUri); string baseUri);
@ -34,5 +34,6 @@ namespace Ombi.Api.Emby
Task<EmbyItemContainer<EmbySeries>> RecentlyAddedShows(string apiKey, string parentIdFilder, int startIndex, int count, string userId, string baseUri); Task<EmbyItemContainer<EmbySeries>> RecentlyAddedShows(string apiKey, string parentIdFilder, int startIndex, int count, string userId, string baseUri);
Task<EmbyItemContainer<EmbyMovie>> GetMoviesPlayed(string apiKey, string parentIdFilder, int startIndex, int count, string userId, string baseUri); Task<EmbyItemContainer<EmbyMovie>> GetMoviesPlayed(string apiKey, string parentIdFilder, int startIndex, int count, string userId, string baseUri);
Task<EmbyItemContainer<EmbyEpisodes>> GetTvPlayed(string apiKey, string parentIdFilder, int startIndex, int count, string userId, string baseUri);
} }
} }

@ -21,7 +21,7 @@ namespace Ombi.Api.Plex
Task<PlexMetadata> GetMetadata(string authToken, string plexFullHost, string itemId); Task<PlexMetadata> GetMetadata(string authToken, string plexFullHost, string itemId);
Task<PlexMetadata> GetSeasons(string authToken, string plexFullHost, string ratingKey); Task<PlexMetadata> GetSeasons(string authToken, string plexFullHost, string ratingKey);
Task<PlexContainer> GetAllEpisodes(string authToken, string host, string section, int start, int retCount); Task<PlexContainer> GetAllEpisodes(string authToken, string host, string section, int start, int retCount);
Task<PlexFriends> GetUsers(string authToken); Task<PlexUsers> GetUsers(string authToken);
Task<PlexAccount> GetAccount(string authToken); Task<PlexAccount> GetAccount(string authToken);
Task<PlexMetadata> GetRecentlyAdded(string authToken, string uri, string sectionId); Task<PlexMetadata> GetRecentlyAdded(string authToken, string uri, string sectionId);
Task<OAuthContainer> GetPin(int pinId); Task<OAuthContainer> GetPin(int pinId);

@ -2,46 +2,30 @@
namespace Ombi.Api.Plex.Models.Friends namespace Ombi.Api.Plex.Models.Friends
{ {
[XmlRoot(ElementName = "Server")]
public class Server
{
[XmlAttribute(AttributeName = "id")]
public string Id { get; set; }
[XmlAttribute(AttributeName = "serverId")]
public string ServerId { get; set; }
[XmlAttribute(AttributeName = "machineIdentifier")]
public string MachineIdentifier { get; set; }
[XmlAttribute(AttributeName = "name")]
public string Name { get; set; }
[XmlAttribute(AttributeName = "lastSeenAt")]
public string LastSeenAt { get; set; }
[XmlAttribute(AttributeName = "numLibraries")]
public string NumLibraries { get; set; }
[XmlAttribute(AttributeName = "owned")]
public string Owned { get; set; }
}
[XmlRoot(ElementName = "User")] [XmlRoot(ElementName = "User")]
public class UserFriends public class UserFriends
{ {
[XmlElement(ElementName = "Server")]
public Server Server { get; set; }
[XmlAttribute(AttributeName = "id")] [XmlAttribute(AttributeName = "id")]
public string Id { get; set; } public string Id { get; set; }
/// <summary>
/// Title is for Home Users only
/// </summary>
[XmlAttribute(AttributeName = "title")] [XmlAttribute(AttributeName = "title")]
public string Title { get; set; } public string Title { get; set; }
[XmlAttribute(AttributeName = "username")] [XmlAttribute(AttributeName = "username")]
public string Username { get; set; } public string Username { get; set; }
[XmlAttribute(AttributeName = "email")] [XmlAttribute(AttributeName = "email")]
public string Email { get; set; } public string Email { get; set; }
[XmlAttribute(AttributeName = "recommendationsPlaylistId")] /// <summary>
public string RecommendationsPlaylistId { get; set; } /// DO NOT USE THIS
[XmlAttribute(AttributeName = "thumb")] /// Home Users can actually be an unmanaged account with an email/username to log in.
public string Thumb { get; set; } /// </summary>
[XmlAttribute(AttributeName = "home")]
public bool HomeUser { get; set; }
} }
[XmlRoot(ElementName = "MediaContainer")] [XmlRoot(ElementName = "MediaContainer")]
public class PlexFriends public class PlexUsers
{ {
[XmlElement(ElementName = "User")] [XmlElement(ElementName = "User")]
public UserFriends[] User { get; set; } public UserFriends[] User { get; set; }

@ -65,7 +65,7 @@ namespace Ombi.Api.Plex
} }
private const string SignInUri = "https://plex.tv/users/sign_in.json"; private const string SignInUri = "https://plex.tv/users/sign_in.json";
private const string FriendsUri = "https://plex.tv/pms/friends/all"; private const string FriendsUri = "https://plex.tv/api/users";
private const string GetAccountUri = "https://plex.tv/users/account.json"; private const string GetAccountUri = "https://plex.tv/users/account.json";
private const string ServerUri = "https://plex.tv/pms/servers.xml"; private const string ServerUri = "https://plex.tv/pms/servers.xml";
private const string WatchlistUri = "https://metadata.provider.plex.tv/"; private const string WatchlistUri = "https://metadata.provider.plex.tv/";
@ -195,12 +195,12 @@ namespace Ombi.Api.Plex
/// </summary> /// </summary>
/// <param name="authToken"></param> /// <param name="authToken"></param>
/// <returns></returns> /// <returns></returns>
public async Task<PlexFriends> GetUsers(string authToken) public async Task<PlexUsers> GetUsers(string authToken)
{ {
var request = new Request(string.Empty, FriendsUri, HttpMethod.Get, ContentType.Xml); var request = new Request(string.Empty, FriendsUri, HttpMethod.Get, ContentType.Xml);
await AddHeaders(request, authToken); await AddHeaders(request, authToken);
return await Api.Request<PlexFriends>(request); return await Api.Request<PlexUsers>(request);
} }
public async Task<PlexMetadata> GetRecentlyAdded(string authToken, string uri, string sectionId) public async Task<PlexMetadata> GetRecentlyAdded(string authToken, string uri, string sectionId)

@ -8,7 +8,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AutoFixture" Version="4.17.0" /> <PackageReference Include="AutoFixture" Version="4.18.0" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="6.0.0" /> <PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="6.0.0" />
<PackageReference Include="Moq" Version="4.18.2" /> <PackageReference Include="Moq" Version="4.18.2" />
<PackageReference Include="Moq.AutoMock" Version="3.4.0" /> <PackageReference Include="Moq.AutoMock" Version="3.4.0" />
@ -18,8 +18,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.2.0" /> <packagereference Include="Microsoft.NET.Test.Sdk" Version="17.6.2"></packagereference>
<packagereference Include="Microsoft.NET.Test.Sdk" Version="17.3.2"></packagereference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

@ -54,6 +54,17 @@ namespace Ombi.Core.Tests.Rule.Request
Assert.True(request.Approved); Assert.True(request.Approved);
} }
[Test]
public async Task Should_ReturnSuccess_WhenAdminAndRequest4KMovie()
{
UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.Admin)).ReturnsAsync(true);
var request = new MovieRequests() { RequestType = Store.Entities.RequestType.Movie, Is4kRequest = true };
var result = await Rule.Execute(request);
Assert.True(result.Success);
Assert.True(request.Approved);
}
[Test] [Test]
public async Task Should_ReturnSuccess_WhenAdminAndRequestTV() public async Task Should_ReturnSuccess_WhenAdminAndRequestTV()
{ {
@ -76,6 +87,17 @@ namespace Ombi.Core.Tests.Rule.Request
Assert.True(request.Approved); Assert.True(request.Approved);
} }
[Test]
public async Task Should_ReturnSuccess_WhenAutoApprove4KMovieAndRequest4KMovie()
{
UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.AutoApprove4KMovie)).ReturnsAsync(true);
var request = new MovieRequests() { RequestType = Store.Entities.RequestType.Movie, Is4kRequest = true };
var result = await Rule.Execute(request);
Assert.True(result.Success);
Assert.True(request.Approved);
}
[Test] [Test]
public async Task Should_ReturnFail_WhenAutoApproveMovie_And_RequestTV() public async Task Should_ReturnFail_WhenAutoApproveMovie_And_RequestTV()
{ {
@ -115,7 +137,7 @@ namespace Ombi.Core.Tests.Rule.Request
public async Task Should_ReturnFail_WhenAutoApproveTV_And_RequestMovie() public async Task Should_ReturnFail_WhenAutoApproveTV_And_RequestMovie()
{ {
UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.AutoApproveTv)).ReturnsAsync(true); UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.AutoApproveTv)).ReturnsAsync(true);
var request = new BaseRequest() { RequestType = Store.Entities.RequestType.Movie }; var request = new MovieRequests() { RequestType = Store.Entities.RequestType.Movie };
var result = await Rule.Execute(request); var result = await Rule.Execute(request);
Assert.True(result.Success); Assert.True(result.Success);
@ -126,7 +148,7 @@ namespace Ombi.Core.Tests.Rule.Request
public async Task Should_ReturnFail_WhenNoClaimsAndRequestMovie() public async Task Should_ReturnFail_WhenNoClaimsAndRequestMovie()
{ {
UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), It.IsAny<string>())).ReturnsAsync(false); UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), It.IsAny<string>())).ReturnsAsync(false);
var request = new BaseRequest() { RequestType = Store.Entities.RequestType.Movie }; var request = new MovieRequests() { RequestType = Store.Entities.RequestType.Movie };
var result = await Rule.Execute(request); var result = await Rule.Execute(request);
Assert.True(result.Success); Assert.True(result.Success);

@ -51,11 +51,20 @@ namespace Ombi.Core.Tests.Rule.Request
} }
[Test] [Test]
public async Task Should_ReturnSuccess_WhenRequestingMovie4KWithMovieRole() public async Task Should_ReturnSuccess_WhenRequestingMovieWithAutoApproveRole()
{
UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.AutoApproveMovie)).ReturnsAsync(true);
var request = new MovieRequests() { RequestType = Store.Entities.RequestType.Movie };
var result = await Rule.Execute(request);
Assert.True(result.Success);
}
[Test]
public async Task Should_ReturnSuccess_WhenRequestingMovie4KWithMovie4KRole()
{ {
UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.RequestMovie)).ReturnsAsync(true);
UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.Request4KMovie)).ReturnsAsync(true); UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.Request4KMovie)).ReturnsAsync(true);
var request = new MovieRequests() { RequestType = Store.Entities.RequestType.Movie, Has4KRequest = true }; var request = new MovieRequests() { RequestType = Store.Entities.RequestType.Movie, Is4kRequest = true };
var result = await Rule.Execute(request); var result = await Rule.Execute(request);
Assert.True(result.Success); Assert.True(result.Success);
@ -74,15 +83,29 @@ namespace Ombi.Core.Tests.Rule.Request
} }
[Test] [Test]
public async Task Should_ReturnSuccess_WhenRequestingMovie4KWithAutoApprove() public async Task Should_ReturnSuccess_WhenRequestingMovie4KWithAutoApprove4K()
{
UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.RequestMovie)).ReturnsAsync(false);
UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.AutoApproveMovie)).ReturnsAsync(false);
UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.Request4KMovie)).ReturnsAsync(false);
UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.AutoApprove4KMovie)).ReturnsAsync(true);
var request = new MovieRequests() { RequestType = Store.Entities.RequestType.Movie, Is4kRequest = true };
var result = await Rule.Execute(request);
Assert.True(result.Success);
}
[Test]
public async Task Should_ReturnFailure_WhenRequestingMovie4KWithout4KRoles()
{ {
UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.RequestMovie)).ReturnsAsync(true); UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.RequestMovie)).ReturnsAsync(true);
UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.AutoApproveMovie)).ReturnsAsync(true); UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.AutoApproveMovie)).ReturnsAsync(true);
UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.Request4KMovie)).ReturnsAsync(false); UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.Request4KMovie)).ReturnsAsync(false);
var request = new MovieRequests() { RequestType = Store.Entities.RequestType.Movie, Has4KRequest = true }; UserManager.Setup(x => x.IsInRoleAsync(It.IsAny<OmbiUser>(), OmbiRoles.AutoApprove4KMovie)).ReturnsAsync(false);
var request = new MovieRequests() { RequestType = Store.Entities.RequestType.Movie, Is4kRequest = true };
var result = await Rule.Execute(request); var result = await Rule.Execute(request);
Assert.True(result.Success); Assert.False(result.Success);
} }
[Test] [Test]

@ -69,6 +69,8 @@ namespace Ombi.Core.Authentication
private readonly ISettingsService<EmbySettings> _embySettings; private readonly ISettingsService<EmbySettings> _embySettings;
private readonly ISettingsService<JellyfinSettings> _jellyfinSettings; private readonly ISettingsService<JellyfinSettings> _jellyfinSettings;
private readonly ISettingsService<AuthenticationSettings> _authSettings; private readonly ISettingsService<AuthenticationSettings> _authSettings;
private string _clientIpAddress;
public string ClientIpAddress { get => _clientIpAddress; set => _clientIpAddress = value; }
public override async Task<bool> CheckPasswordAsync(OmbiUser user, string password) public override async Task<bool> CheckPasswordAsync(OmbiUser user, string password)
{ {
@ -88,7 +90,7 @@ namespace Ombi.Core.Authentication
} }
if (user.UserType == UserType.EmbyUser || user.UserType == UserType.EmbyConnectUser) if (user.UserType == UserType.EmbyUser || user.UserType == UserType.EmbyConnectUser)
{ {
return await CheckEmbyPasswordAsync(user, password); return await CheckEmbyPasswordAsync(user, password, ClientIpAddress);
} }
if (user.UserType == UserType.JellyfinUser) if (user.UserType == UserType.JellyfinUser)
{ {
@ -168,7 +170,7 @@ namespace Ombi.Core.Authentication
/// <param name="user"></param> /// <param name="user"></param>
/// <param name="password"></param> /// <param name="password"></param>
/// <returns></returns> /// <returns></returns>
private async Task<bool> CheckEmbyPasswordAsync(OmbiUser user, string password) private async Task<bool> CheckEmbyPasswordAsync(OmbiUser user, string password, string clientIpAddress="")
{ {
var embySettings = await _embySettings.GetSettingsAsync(); var embySettings = await _embySettings.GetSettingsAsync();
var client = _embyApi.CreateClient(embySettings); var client = _embyApi.CreateClient(embySettings);
@ -196,7 +198,7 @@ namespace Ombi.Core.Authentication
{ {
try try
{ {
var result = await client.LogIn(user.UserName, password, server.ApiKey, server.FullUri); var result = await client.LogIn(user.UserName, password, server.ApiKey, server.FullUri, clientIpAddress);
if (result != null) if (result != null)
{ {
return true; return true;

@ -35,7 +35,8 @@ namespace Ombi.Core.Engine
public TvRequestEngine(ITvMazeApi tvApi, IMovieDbApi movApi, IRequestServiceMain requestService, ICurrentUser user, public TvRequestEngine(ITvMazeApi tvApi, IMovieDbApi movApi, IRequestServiceMain requestService, ICurrentUser user,
INotificationHelper helper, IRuleEvaluator rule, OmbiUserManager manager, ILogger<TvRequestEngine> logger, INotificationHelper helper, IRuleEvaluator rule, OmbiUserManager manager, ILogger<TvRequestEngine> logger,
ITvSender sender, IRepository<RequestLog> rl, ISettingsService<OmbiSettings> settings, ICacheService cache, ITvSender sender, IRepository<RequestLog> rl, ISettingsService<OmbiSettings> settings, ICacheService cache,
IRepository<RequestSubscription> sub, IMediaCacheService mediaCacheService) : base(user, requestService, rule, manager, cache, settings, sub) IRepository<RequestSubscription> sub, IMediaCacheService mediaCacheService,
IUserPlayedEpisodeRepository userPlayedEpisodeRepository) : base(user, requestService, rule, manager, cache, settings, sub)
{ {
TvApi = tvApi; TvApi = tvApi;
MovieDbApi = movApi; MovieDbApi = movApi;
@ -44,6 +45,7 @@ namespace Ombi.Core.Engine
TvSender = sender; TvSender = sender;
_requestLog = rl; _requestLog = rl;
_mediaCacheService = mediaCacheService; _mediaCacheService = mediaCacheService;
_userPlayedEpisodeRepository = userPlayedEpisodeRepository;
} }
private INotificationHelper NotificationHelper { get; } private INotificationHelper NotificationHelper { get; }
@ -54,6 +56,7 @@ namespace Ombi.Core.Engine
private readonly ILogger<TvRequestEngine> _logger; private readonly ILogger<TvRequestEngine> _logger;
private readonly IRepository<RequestLog> _requestLog; private readonly IRepository<RequestLog> _requestLog;
private readonly IMediaCacheService _mediaCacheService; private readonly IMediaCacheService _mediaCacheService;
private readonly IUserPlayedEpisodeRepository _userPlayedEpisodeRepository;
public async Task<RequestEngineResult> RequestTvShow(TvRequestViewModel tv) public async Task<RequestEngineResult> RequestTvShow(TvRequestViewModel tv)
{ {
@ -292,7 +295,7 @@ namespace Ombi.Core.Engine
.Skip(position).Take(count).ToListAsync(); .Skip(position).Take(count).ToListAsync();
} }
await CheckForSubscription(shouldHide, allRequests); await FillAdditionalFields(shouldHide, allRequests);
return new RequestsViewModel<TvRequests> return new RequestsViewModel<TvRequests>
{ {
@ -328,7 +331,7 @@ namespace Ombi.Core.Engine
return new RequestsViewModel<TvRequests>(); return new RequestsViewModel<TvRequests>();
} }
await CheckForSubscription(shouldHide, allRequests); await FillAdditionalFields(shouldHide, allRequests);
return new RequestsViewModel<TvRequests> return new RequestsViewModel<TvRequests>
{ {
@ -351,7 +354,7 @@ namespace Ombi.Core.Engine
allRequests = await TvRepository.Get().ToListAsync(); allRequests = await TvRepository.Get().ToListAsync();
} }
await CheckForSubscription(shouldHide, allRequests); await FillAdditionalFields(shouldHide, allRequests);
return allRequests; return allRequests;
} }
@ -396,7 +399,7 @@ namespace Ombi.Core.Engine
? allRequests.OrderBy(x => prop.GetValue(x)).ToList() ? allRequests.OrderBy(x => prop.GetValue(x)).ToList()
: allRequests.OrderByDescending(x => prop.GetValue(x)).ToList(); : allRequests.OrderByDescending(x => prop.GetValue(x)).ToList();
await CheckForSubscription(shouldHide, allRequests); await FillAdditionalFields(shouldHide, allRequests);
// Make sure we do not show duplicate child requests // Make sure we do not show duplicate child requests
allRequests = allRequests.DistinctBy(x => x.ParentRequest.Title).ToList(); allRequests = allRequests.DistinctBy(x => x.ParentRequest.Title).ToList();
@ -469,7 +472,7 @@ namespace Ombi.Core.Engine
? allRequests.OrderBy(x => prop.GetValue(x)).ToList() ? allRequests.OrderBy(x => prop.GetValue(x)).ToList()
: allRequests.OrderByDescending(x => prop.GetValue(x)).ToList(); : allRequests.OrderByDescending(x => prop.GetValue(x)).ToList();
await CheckForSubscription(shouldHide, allRequests); await FillAdditionalFields(shouldHide, allRequests);
// Make sure we do not show duplicate child requests // Make sure we do not show duplicate child requests
allRequests = allRequests.DistinctBy(x => x.ParentRequest.Title).ToList(); allRequests = allRequests.DistinctBy(x => x.ParentRequest.Title).ToList();
@ -523,7 +526,7 @@ namespace Ombi.Core.Engine
allRequests = sortOrder.Equals("asc", StringComparison.InvariantCultureIgnoreCase) allRequests = sortOrder.Equals("asc", StringComparison.InvariantCultureIgnoreCase)
? allRequests.OrderBy(x => prop.GetValue(x)).ToList() ? allRequests.OrderBy(x => prop.GetValue(x)).ToList()
: allRequests.OrderByDescending(x => prop.GetValue(x)).ToList(); : allRequests.OrderByDescending(x => prop.GetValue(x)).ToList();
await CheckForSubscription(shouldHide, allRequests); await FillAdditionalFields(shouldHide, allRequests);
// Make sure we do not show duplicate child requests // Make sure we do not show duplicate child requests
allRequests = allRequests.DistinctBy(x => x.ParentRequest.Title).ToList(); allRequests = allRequests.DistinctBy(x => x.ParentRequest.Title).ToList();
@ -551,7 +554,7 @@ namespace Ombi.Core.Engine
allRequests = await TvRepository.GetLite().ToListAsync(); allRequests = await TvRepository.GetLite().ToListAsync();
} }
await CheckForSubscription(shouldHide, allRequests); await FillAdditionalFields(shouldHide, allRequests);
return allRequests; return allRequests;
} }
@ -570,7 +573,7 @@ namespace Ombi.Core.Engine
request = await TvRepository.Get().Where(x => x.Id == requestId).FirstOrDefaultAsync(); request = await TvRepository.Get().Where(x => x.Id == requestId).FirstOrDefaultAsync();
} }
await CheckForSubscription(shouldHide, new List<TvRequests>{request}); await FillAdditionalFields(shouldHide, new List<TvRequests>{request});
return request; return request;
} }
@ -624,7 +627,7 @@ namespace Ombi.Core.Engine
allRequests = await TvRepository.GetChild().Include(x => x.SeasonRequests).Where(x => x.ParentRequestId == tvId).ToListAsync(); allRequests = await TvRepository.GetChild().Include(x => x.SeasonRequests).Where(x => x.ParentRequestId == tvId).ToListAsync();
} }
await CheckForSubscription(shouldHide, allRequests); await FillAdditionalFields(shouldHide, allRequests);
return allRequests; return allRequests;
} }
@ -643,7 +646,7 @@ namespace Ombi.Core.Engine
} }
var results = await allRequests.Where(x => x.Title.Contains(search, CompareOptions.IgnoreCase)).ToListAsync(); var results = await allRequests.Where(x => x.Title.Contains(search, CompareOptions.IgnoreCase)).ToListAsync();
await CheckForSubscription(shouldHide, results); await FillAdditionalFields(shouldHide, results);
return results; return results;
} }
@ -864,14 +867,20 @@ namespace Ombi.Core.Engine
} }
} }
private async Task CheckForSubscription(HideResult shouldHide, List<TvRequests> x) private async Task FillAdditionalFields(HideResult shouldHide, List<TvRequests> x)
{ {
foreach (var tvRequest in x) foreach (var tvRequest in x)
{ {
await CheckForSubscription(shouldHide, tvRequest.ChildRequests); await FillAdditionalFields(shouldHide, tvRequest.ChildRequests);
} }
} }
private async Task FillAdditionalFields(HideResult shouldHide, List<ChildRequests> childRequests)
{
await CheckForSubscription(shouldHide, childRequests);
CheckForPlayed(shouldHide, childRequests);
}
private async Task CheckForSubscription(HideResult shouldHide, List<ChildRequests> childRequests) private async Task CheckForSubscription(HideResult shouldHide, List<ChildRequests> childRequests)
{ {
var sub = _subscriptionRepository.GetAll(); var sub = _subscriptionRepository.GetAll();
@ -896,6 +905,59 @@ namespace Ombi.Core.Engine
} }
} }
private class EpisodeKey
{
public int SeasonNumber;
public int EpisodeNumber;
}
private void CheckForPlayed(HideResult shouldHide, List<ChildRequests> childRequests)
{
var theMovieDbIds = childRequests.Select(x => x.Id);
foreach (var request in childRequests)
{
var requestedEpisodes = GetEpisodesKeys(request);
var playedEpisodes = _userPlayedEpisodeRepository
.GetAll()
.Where(x => x.TheMovieDbId == request.Id && x.UserId == request.RequestedUserId)
.AsEnumerable()
.Join(requestedEpisodes,
played => new { played.SeasonNumber, played.EpisodeNumber },
requested => new { requested.SeasonNumber, requested.EpisodeNumber },
(played, requested) => new { played });
var playedCount = playedEpisodes.Count();
var toWatchCount = requestedEpisodes.Count();
if (playedCount == 0 || toWatchCount == 0)
{
request.RequestedUserPlayedProgress = 0;
}
else
{
request.RequestedUserPlayedProgress = 100 * playedCount / toWatchCount;
}
}
}
private List<EpisodeKey> GetEpisodesKeys(ChildRequests request)
{
List<EpisodeKey> result = new List<EpisodeKey>();
foreach(var season in request.SeasonRequests)
{
foreach(var episode in season.Episodes)
{
result.Add(new EpisodeKey
{
SeasonNumber = season.SeasonNumber,
EpisodeNumber = episode.EpisodeNumber
});
}
}
return result;
}
private async Task<RequestEngineResult> AddExistingRequest(ChildRequests newRequest, TvRequests existingRequest, string requestOnBehalf, int rootFolder, int qualityProfile) private async Task<RequestEngineResult> AddExistingRequest(ChildRequests newRequest, TvRequests existingRequest, string requestOnBehalf, int rootFolder, int qualityProfile)
{ {
// Add the child // Add the child

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.VisualBasic;
using Ombi.Core.Authentication; using Ombi.Core.Authentication;
using Ombi.Store.Entities; using Ombi.Store.Entities;
using Ombi.Store.Entities.Requests; using Ombi.Store.Entities.Requests;

@ -200,19 +200,14 @@ namespace Ombi.Core.Engine.V2
public async Task<IEnumerable<SearchMovieViewModel>> NowPlayingMovies(int currentPosition, int amountToLoad) public async Task<IEnumerable<SearchMovieViewModel>> NowPlayingMovies(int currentPosition, int amountToLoad)
{ {
var langCode = await DefaultLanguageCode(null); var langCode = await DefaultLanguageCode(null);
var isOldTrendingSourceEnabled = await _feature.FeatureEnabled(FeatureNames.OldTrendingSource);
var pages = PaginationHelper.GetNextPages(currentPosition, amountToLoad, _theMovieDbMaxPageItems); var pages = PaginationHelper.GetNextPages(currentPosition, amountToLoad, _theMovieDbMaxPageItems);
var results = new List<MovieDbSearchResult>(); var results = new List<MovieDbSearchResult>();
foreach (var pagesToLoad in pages) foreach (var pagesToLoad in pages)
{ {
var search = () => (isOldTrendingSourceEnabled) ?
MovieApi.NowPlaying(langCode, pagesToLoad.Page)
: MovieApi.TrendingMovies(langCode, pagesToLoad.Page);
var apiResult = await Cache.GetOrAddAsync(nameof(NowPlayingMovies) + pagesToLoad.Page + langCode, var apiResult = await Cache.GetOrAddAsync(nameof(NowPlayingMovies) + pagesToLoad.Page + langCode,
search, DateTimeOffset.Now.AddHours(12)); () => MovieApi.TrendingMovies(langCode, pagesToLoad.Page), DateTimeOffset.Now.AddHours(12));
results.AddRange(apiResult.Skip(pagesToLoad.Skip).Take(pagesToLoad.Take)); results.AddRange(apiResult.Skip(pagesToLoad.Skip).Take(pagesToLoad.Take));
} }
return await TransformMovieResultsToResponse(results); return await TransformMovieResultsToResponse(results);

@ -138,17 +138,13 @@ namespace Ombi.Core.Engine.V2
public async Task<IEnumerable<SearchTvShowViewModel>> Trending(int currentlyLoaded, int amountToLoad) public async Task<IEnumerable<SearchTvShowViewModel>> Trending(int currentlyLoaded, int amountToLoad)
{ {
var langCode = await DefaultLanguageCode(null); var langCode = await DefaultLanguageCode(null);
var isOldTrendingSourceEnabled = await _feature.FeatureEnabled(FeatureNames.OldTrendingSource);
var pages = PaginationHelper.GetNextPages(currentlyLoaded, amountToLoad, ResultLimit); var pages = PaginationHelper.GetNextPages(currentlyLoaded, amountToLoad, ResultLimit);
var results = new List<MovieDbSearchResult>(); var results = new List<MovieDbSearchResult>();
foreach (var pagesToLoad in pages) foreach (var pagesToLoad in pages)
{ {
var search = ( async () => (isOldTrendingSourceEnabled) ?
await _movieApi.TopRatedTv(langCode, pagesToLoad.Page)
: await _movieApi.TrendingTv(langCode, pagesToLoad.Page));
var apiResult = await Cache.GetOrAddAsync(nameof(Trending) + langCode + pagesToLoad.Page, var apiResult = await Cache.GetOrAddAsync(nameof(Trending) + langCode + pagesToLoad.Page,
search, DateTimeOffset.Now.AddHours(12)); () => _movieApi.TrendingTv(langCode, pagesToLoad.Page), DateTimeOffset.Now.AddHours(12));
results.AddRange(apiResult.Skip(pagesToLoad.Skip).Take(pagesToLoad.Take)); results.AddRange(apiResult.Skip(pagesToLoad.Skip).Take(pagesToLoad.Take));
} }

@ -36,7 +36,7 @@ namespace Ombi.Core.Rule.Rules.Request
{ {
if (obj is MovieRequests movie) if (obj is MovieRequests movie)
{ {
await Check4K(movie); await ApproveMovie(movie);
} }
else else
{ {
@ -45,10 +45,14 @@ namespace Ombi.Core.Rule.Rules.Request
return Success(); return Success();
} }
if (obj.RequestType == RequestType.Movie && await _manager.IsInRoleAsync(user, OmbiRoles.AutoApproveMovie)) if (obj.RequestType == RequestType.Movie)
{ {
var movie = (MovieRequests)obj; var movie = (MovieRequests)obj;
await Check4K(movie); var autoApproveRole = movie.Is4kRequest ? OmbiRoles.AutoApprove4KMovie : OmbiRoles.AutoApproveMovie;
if (await _manager.IsInRoleAsync(user, autoApproveRole))
{
await ApproveMovie(movie);
}
} }
if (obj.RequestType == RequestType.TvShow && await _manager.IsInRoleAsync(user, OmbiRoles.AutoApproveTv)) if (obj.RequestType == RequestType.TvShow && await _manager.IsInRoleAsync(user, OmbiRoles.AutoApproveTv))
obj.Approved = true; obj.Approved = true;
@ -57,7 +61,7 @@ namespace Ombi.Core.Rule.Rules.Request
return Success(); // We don't really care, we just don't set the obj to approve return Success(); // We don't really care, we just don't set the obj to approve
} }
private async Task Check4K(MovieRequests movie) private async Task ApproveMovie(MovieRequests movie)
{ {
var featureEnabled = await _featureService.FeatureEnabled(FeatureNames.Movie4KRequests); var featureEnabled = await _featureService.FeatureEnabled(FeatureNames.Movie4KRequests);
if (movie.Is4kRequest && featureEnabled) if (movie.Is4kRequest && featureEnabled)

@ -36,21 +36,13 @@ namespace Ombi.Core.Rule.Rules.Request
if (obj.RequestType == RequestType.Movie) if (obj.RequestType == RequestType.Movie)
{ {
var movie = (MovieRequests)obj; var movie = (MovieRequests)obj;
var hasAutoApprove = await _manager.IsInRoleAsync(user, OmbiRoles.AutoApproveMovie);
if (await _manager.IsInRoleAsync(user, OmbiRoles.RequestMovie) || hasAutoApprove) var requestRole = movie.Is4kRequest ? OmbiRoles.Request4KMovie : OmbiRoles.RequestMovie;
var autoApproveRole = movie.Is4kRequest ? OmbiRoles.AutoApprove4KMovie : OmbiRoles.AutoApproveMovie;
if (await _manager.IsInRoleAsync(user, requestRole) || await _manager.IsInRoleAsync(user, autoApproveRole))
{ {
if (movie.Is4kRequest && !hasAutoApprove) return Success();
{
var has4kPermission = await _manager.IsInRoleAsync(user, OmbiRoles.Request4KMovie);
if (has4kPermission)
{
return Success();
}
}
else
{
return Success();
}
} }
return Fail(ErrorCode.NoPermissionsRequestMovie, "You do not have permissions to Request a Movie"); return Fail(ErrorCode.NoPermissionsRequestMovie, "You do not have permissions to Request a Movie");
} }

@ -198,6 +198,7 @@ namespace Ombi.DependencyInjection
services.AddScoped<IJellyfinContentRepository, JellyfinContentRepository>(); services.AddScoped<IJellyfinContentRepository, JellyfinContentRepository>();
services.AddScoped<INotificationTemplatesRepository, NotificationTemplatesRepository>(); services.AddScoped<INotificationTemplatesRepository, NotificationTemplatesRepository>();
services.AddScoped<IUserPlayedMovieRepository, UserPlayedMovieRepository>(); services.AddScoped<IUserPlayedMovieRepository, UserPlayedMovieRepository>();
services.AddScoped<IUserPlayedEpisodeRepository, UserPlayedEpisodeRepository>();
services.AddScoped<ITvRequestRepository, TvRequestRepository>(); services.AddScoped<ITvRequestRepository, TvRequestRepository>();
services.AddScoped<IMovieRequestRepository, MovieRequestRepository>(); services.AddScoped<IMovieRequestRepository, MovieRequestRepository>();

@ -12,7 +12,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="6.0.9" /> <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="6.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
</ItemGroup> </ItemGroup>

@ -1,8 +1,5 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;

@ -1,17 +1,9 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Ombi.Api.CouchPotato; using Ombi.Api.CouchPotato;
using Ombi.Api.Emby;
using Ombi.Api.Emby.Models;
using Ombi.Api.Jellyfin;
using Ombi.Api.Jellyfin.Models;
using Ombi.Api.Plex;
using Ombi.Api.Plex.Models.Status;
using Ombi.Core.Settings; using Ombi.Core.Settings;
using Ombi.Core.Settings.Models.External;
using Ombi.Settings.Settings.Models.External; using Ombi.Settings.Settings.Models.External;
using System; using System;
using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;

@ -2,8 +2,6 @@
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Ombi.Api.Emby; using Ombi.Api.Emby;
using Ombi.Api.Emby.Models; using Ombi.Api.Emby.Models;
using Ombi.Api.Plex;
using Ombi.Api.Plex.Models.Status;
using Ombi.Core.Settings; using Ombi.Core.Settings;
using Ombi.Core.Settings.Models.External; using Ombi.Core.Settings.Models.External;
using System; using System;

@ -2,8 +2,6 @@
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Ombi.Api.Jellyfin; using Ombi.Api.Jellyfin;
using Ombi.Api.Jellyfin.Models; using Ombi.Api.Jellyfin.Models;
using Ombi.Api.Plex;
using Ombi.Api.Plex.Models.Status;
using Ombi.Core.Settings; using Ombi.Core.Settings;
using Ombi.Core.Settings.Models.External; using Ombi.Core.Settings.Models.External;
using System; using System;

@ -1,18 +1,9 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Ombi.Api.CouchPotato;
using Ombi.Api.Emby;
using Ombi.Api.Emby.Models;
using Ombi.Api.Jellyfin;
using Ombi.Api.Jellyfin.Models;
using Ombi.Api.Plex;
using Ombi.Api.Plex.Models.Status;
using Ombi.Api.SickRage; using Ombi.Api.SickRage;
using Ombi.Core.Settings; using Ombi.Core.Settings;
using Ombi.Core.Settings.Models.External;
using Ombi.Settings.Settings.Models.External; using Ombi.Settings.Settings.Models.External;
using System; using System;
using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;

@ -1,8 +1,5 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Ombi.HealthChecks.Checks; using Ombi.HealthChecks.Checks;
using System;
using System.Collections.Generic;
namespace Ombi.HealthChecks namespace Ombi.HealthChecks
{ {

@ -7,7 +7,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.Network" Version="6.0.4" /> <PackageReference Include="AspNetCore.HealthChecks.Network" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.HealthChecks" Version="2.2.0" />
<PackageReference Include="System.Collections" Version="4.3.0" /> <PackageReference Include="System.Collections" Version="4.3.0" />
</ItemGroup> </ItemGroup>

@ -12,7 +12,7 @@
<PackageReference Include="nunit" Version="3.13.3" /> <PackageReference Include="nunit" Version="3.13.3" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="6.0.0" /> <PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="6.0.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" /> <PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

@ -11,13 +11,13 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="EasyCrypto" Version="4.5.0" /> <PackageReference Include="EasyCrypto" Version="4.6.0" />
<PackageReference Include="LazyCache.AspNetCore" Version="2.4.0" /> <PackageReference Include="LazyCache.AspNetCore" Version="2.4.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.2" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Nito.AsyncEx" Version="5.1.2" /> <PackageReference Include="Nito.AsyncEx" Version="5.1.2" />
<PackageReference Include="Quartz" Version="3.5.0" /> <PackageReference Include="Quartz" Version="3.6.2" />
<PackageReference Include="System.Security.Claims" Version="4.3.0" /> <PackageReference Include="System.Security.Claims" Version="4.3.0" />
</ItemGroup> </ItemGroup>

@ -2,7 +2,7 @@
{ {
public static class OmbiRoles public static class OmbiRoles
{ {
// DONT FORGET TO ADD TO IDENTITYCONTROLLER.CREATEROLES AND THE UI! // DONT FORGET TO ADD TO IDENTITYCONTROLLER.CREATEROLES!
public const string Admin = nameof(Admin); public const string Admin = nameof(Admin);
public const string AutoApproveMovie = nameof(AutoApproveMovie); public const string AutoApproveMovie = nameof(AutoApproveMovie);
@ -17,5 +17,6 @@
public const string ManageOwnRequests = nameof(ManageOwnRequests); public const string ManageOwnRequests = nameof(ManageOwnRequests);
public const string EditCustomPage = nameof(EditCustomPage); public const string EditCustomPage = nameof(EditCustomPage);
public const string Request4KMovie = nameof(Request4KMovie); public const string Request4KMovie = nameof(Request4KMovie);
public const string AutoApprove4KMovie = nameof(AutoApprove4KMovie);
} }
} }

@ -6,12 +6,12 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AutoFixture" Version="4.17.0" /> <PackageReference Include="AutoFixture" Version="4.18.0" />
<PackageReference Include="Nunit" Version="3.13.3" /> <PackageReference Include="Nunit" Version="3.13.3" />
<PackageReference Include="NUnit.ConsoleRunner" Version="3.15.2" /> <PackageReference Include="NUnit.ConsoleRunner" Version="3.15.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" /> <PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="6.0.0" /> <PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="6.0.0" />
<packagereference Include="Microsoft.NET.Test.Sdk" Version="17.3.2"></packagereference> <packagereference Include="Microsoft.NET.Test.Sdk" Version="17.6.2"></packagereference>
<PackageReference Include="Moq" Version="4.18.2" /> <PackageReference Include="Moq" Version="4.18.2" />
<PackageReference Include="Moq.AutoMock" Version="3.4.0" /> <PackageReference Include="Moq.AutoMock" Version="3.4.0" />
</ItemGroup> </ItemGroup>

@ -12,7 +12,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Ensure.That" Version="10.1.0" /> <PackageReference Include="Ensure.That" Version="10.1.0" />
<PackageReference Include="MailKit" Version="3.4.1" /> <PackageReference Include="MailKit" Version="4.1.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

@ -6,7 +6,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" />
<PackageReference Include="MockQueryable.Moq" Version="6.0.1" /> <PackageReference Include="MockQueryable.Moq" Version="6.0.1" />
<PackageReference Include="Moq" Version="4.18.2" /> <PackageReference Include="Moq" Version="4.18.2" />
<PackageReference Include="Moq.AutoMock" Version="3.4.0" /> <PackageReference Include="Moq.AutoMock" Version="3.4.0" />
@ -14,7 +13,7 @@
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="6.0.0" /> <PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="6.0.0" />
<PackageReference Include="NUnit.ConsoleRunner" Version="3.15.2" /> <PackageReference Include="NUnit.ConsoleRunner" Version="3.15.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" /> <PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<packagereference Include="Microsoft.NET.Test.Sdk" Version="17.3.2"></packagereference> <packagereference Include="Microsoft.NET.Test.Sdk" Version="17.6.2"></packagereference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

@ -6,6 +6,7 @@ using Ombi.Api.Plex;
using Ombi.Api.Plex.Models; using Ombi.Api.Plex.Models;
using Ombi.Api.Plex.Models.Friends; using Ombi.Api.Plex.Models.Friends;
using Ombi.Core.Authentication; using Ombi.Core.Authentication;
using Ombi.Core.Engine;
using Ombi.Core.Settings; using Ombi.Core.Settings;
using Ombi.Core.Settings.Models.External; using Ombi.Core.Settings.Models.External;
using Ombi.Helpers; using Ombi.Helpers;
@ -31,6 +32,7 @@ namespace Ombi.Schedule.Tests
new OmbiUser { Id = Guid.NewGuid().ToString("N"), UserName="abc", NormalizedUserName = "ABC", UserType = UserType.LocalUser}, new OmbiUser { Id = Guid.NewGuid().ToString("N"), UserName="abc", NormalizedUserName = "ABC", UserType = UserType.LocalUser},
new OmbiUser { Id = Guid.NewGuid().ToString("N"), UserName="sys", NormalizedUserName = "SYS", UserType = UserType.SystemUser}, new OmbiUser { Id = Guid.NewGuid().ToString("N"), UserName="sys", NormalizedUserName = "SYS", UserType = UserType.SystemUser},
new OmbiUser { Id = Guid.NewGuid().ToString("N"), UserName="plex", NormalizedUserName = "PLEX", UserType = UserType.PlexUser, ProviderUserId = "PLEX_ID", Email = "dupe"}, new OmbiUser { Id = Guid.NewGuid().ToString("N"), UserName="plex", NormalizedUserName = "PLEX", UserType = UserType.PlexUser, ProviderUserId = "PLEX_ID", Email = "dupe"},
new OmbiUser { Id = Guid.NewGuid().ToString("N"), UserName="Admin", NormalizedUserName = "ADMIN", UserType = UserType.PlexUser, ProviderUserId = "ADMIN_ID", Email = "ADMIN@ADMIN.CO"},
}; };
private AutoMocker _mocker; private AutoMocker _mocker;
private PlexUserImporter _subject; private PlexUserImporter _subject;
@ -187,7 +189,7 @@ namespace Ombi.Schedule.Tests
{ {
_mocker.Setup<ISettingsService<UserManagementSettings>, Task<UserManagementSettings>>(x => x.GetSettingsAsync()) _mocker.Setup<ISettingsService<UserManagementSettings>, Task<UserManagementSettings>>(x => x.GetSettingsAsync())
.ReturnsAsync(new UserManagementSettings { ImportPlexAdmin = false, ImportPlexUsers = true, BannedPlexUserIds = new List<string> { "Banned" } }); .ReturnsAsync(new UserManagementSettings { ImportPlexAdmin = false, ImportPlexUsers = true, BannedPlexUserIds = new List<string> { "Banned" } });
_mocker.Setup<IPlexApi, Task<PlexFriends>>(x => x.GetUsers(It.IsAny<string>())).ReturnsAsync(new PlexFriends _mocker.Setup<IPlexApi, Task<PlexUsers>>(x => x.GetUsers(It.IsAny<string>())).ReturnsAsync(new PlexUsers
{ {
User = new UserFriends[] User = new UserFriends[]
{ {
@ -211,7 +213,7 @@ namespace Ombi.Schedule.Tests
{ {
_mocker.Setup<ISettingsService<UserManagementSettings>, Task<UserManagementSettings>>(x => x.GetSettingsAsync()) _mocker.Setup<ISettingsService<UserManagementSettings>, Task<UserManagementSettings>>(x => x.GetSettingsAsync())
.ReturnsAsync(new UserManagementSettings { ImportPlexAdmin = false, ImportPlexUsers = true }); .ReturnsAsync(new UserManagementSettings { ImportPlexAdmin = false, ImportPlexUsers = true });
_mocker.Setup<IPlexApi, Task<PlexFriends>>(x => x.GetUsers(It.IsAny<string>())).ReturnsAsync(new PlexFriends _mocker.Setup<IPlexApi, Task<PlexUsers>>(x => x.GetUsers(It.IsAny<string>())).ReturnsAsync(new PlexUsers
{ {
User = new UserFriends[] User = new UserFriends[]
{ {
@ -220,6 +222,7 @@ namespace Ombi.Schedule.Tests
Email = "email", Email = "email",
Id = "id", Id = "id",
Title = "title", Title = "title",
HomeUser = true
} }
} }
}); });
@ -229,12 +232,43 @@ namespace Ombi.Schedule.Tests
_mocker.Verify<OmbiUserManager>(x => x.CreateAsync(It.IsAny<OmbiUser>()), Times.Never); _mocker.Verify<OmbiUserManager>(x => x.CreateAsync(It.IsAny<OmbiUser>()), Times.Never);
} }
[Test(Description = "You can have home users that are now unmanaged and can actually log into Plex")]
public async Task Imports_Unmanaged_Home_User()
{
_mocker.Setup<ISettingsService<UserManagementSettings>, Task<UserManagementSettings>>(x => x.GetSettingsAsync())
.ReturnsAsync(new UserManagementSettings { ImportPlexAdmin = false, ImportPlexUsers = true });
_mocker.Setup<IPlexApi, Task<PlexUsers>>(x => x.GetUsers(It.IsAny<string>())).ReturnsAsync(new PlexUsers
{
User = new UserFriends[]
{
new UserFriends
{
Email = "email",
Id = "id",
Title = "title",
Username = "username",
HomeUser = true
}
}
});
_mocker.Setup<OmbiUserManager, Task<IdentityResult>>(x => x.CreateAsync(It.Is<OmbiUser>(x => x.UserName == "username" && x.Email == "email" && x.ProviderUserId == "id" && x.UserType == UserType.PlexUser)))
.ReturnsAsync(IdentityResult.Success);
_mocker.Setup<OmbiUserManager, Task<IdentityResult>>(x => x.AddToRoleAsync(It.Is<OmbiUser>(x => x.UserName == "plex"), OmbiRoles.RequestMovie))
.ReturnsAsync(IdentityResult.Success);
await _subject.Execute(null);
_mocker.Verify<OmbiUserManager>(x => x.CreateAsync(It.IsAny<OmbiUser>()), Times.Once);
}
[Test] [Test]
public async Task Import_Doesnt_Import_DuplicateEmail() public async Task Import_Doesnt_Import_DuplicateEmail()
{ {
_mocker.Setup<ISettingsService<UserManagementSettings>, Task<UserManagementSettings>>(x => x.GetSettingsAsync()) _mocker.Setup<ISettingsService<UserManagementSettings>, Task<UserManagementSettings>>(x => x.GetSettingsAsync())
.ReturnsAsync(new UserManagementSettings { ImportPlexAdmin = false, ImportPlexUsers = true }); .ReturnsAsync(new UserManagementSettings { ImportPlexAdmin = false, ImportPlexUsers = true });
_mocker.Setup<IPlexApi, Task<PlexFriends>>(x => x.GetUsers(It.IsAny<string>())).ReturnsAsync(new PlexFriends _mocker.Setup<IPlexApi, Task<PlexUsers>>(x => x.GetUsers(It.IsAny<string>())).ReturnsAsync(new PlexUsers
{ {
User = new UserFriends[] User = new UserFriends[]
{ {
@ -264,7 +298,7 @@ namespace Ombi.Schedule.Tests
OmbiRoles.RequestMovie OmbiRoles.RequestMovie
} }
}); });
_mocker.Setup<IPlexApi, Task<PlexFriends>>(x => x.GetUsers(It.IsAny<string>())).ReturnsAsync(new PlexFriends _mocker.Setup<IPlexApi, Task<PlexUsers>>(x => x.GetUsers(It.IsAny<string>())).ReturnsAsync(new PlexUsers
{ {
User = new UserFriends[] User = new UserFriends[]
{ {
@ -301,7 +335,7 @@ namespace Ombi.Schedule.Tests
OmbiRoles.RequestMovie OmbiRoles.RequestMovie
} }
}); });
_mocker.Setup<IPlexApi, Task<PlexFriends>>(x => x.GetUsers(It.IsAny<string>())).ReturnsAsync(new PlexFriends _mocker.Setup<IPlexApi, Task<PlexUsers>>(x => x.GetUsers(It.IsAny<string>())).ReturnsAsync(new PlexUsers
{ {
User = new UserFriends[] User = new UserFriends[]
{ {
@ -340,7 +374,7 @@ namespace Ombi.Schedule.Tests
}, },
CleanupPlexUsers = true, CleanupPlexUsers = true,
}); });
_mocker.Setup<IPlexApi, Task<PlexFriends>>(x => x.GetUsers(It.IsAny<string>())).ReturnsAsync(new PlexFriends _mocker.Setup<IPlexApi, Task<PlexUsers>>(x => x.GetUsers(It.IsAny<string>())).ReturnsAsync(new PlexUsers
{ {
User = new UserFriends[] User = new UserFriends[]
{ {
@ -365,11 +399,11 @@ namespace Ombi.Schedule.Tests
await _subject.Execute(null); await _subject.Execute(null);
_mocker.Verify<OmbiUserManager>(x => x.DeleteAsync(It.Is<OmbiUser>(x => x.ProviderUserId == "PLEX_ID" && x.Email == "dupe" && x.UserName == "plex")), Times.Once); _mocker.Verify<IUserDeletionEngine>(x => x.DeleteUser(It.Is<OmbiUser>(x => x.ProviderUserId == "PLEX_ID" && x.Email == "dupe" && x.UserName == "plex")), Times.Once);
} }
[Test] [Test]
public async Task Import_Cleanup_Missing_Plex_Admin() public async Task Import_Cleanup_Missing_Plex_Admin_Dont_Delete()
{ {
_mocker.Setup<ISettingsService<UserManagementSettings>, Task<UserManagementSettings>>(x => x.GetSettingsAsync()) _mocker.Setup<ISettingsService<UserManagementSettings>, Task<UserManagementSettings>>(x => x.GetSettingsAsync())
.ReturnsAsync(new UserManagementSettings .ReturnsAsync(new UserManagementSettings
@ -386,22 +420,25 @@ namespace Ombi.Schedule.Tests
{ {
user = new User user = new User
{ {
email = "diff_email", email = "ADMIN@ADMIN.CO",
authentication_token = "user_token", authentication_token = "Admin",
title = "user_title", title = "Admin",
username = "diff_username", username = "Admin",
id = "diff_user_id", id = "ADMIN_ID",
} }
}); });
_mocker.Setup<OmbiUserManager, Task<IdentityResult>>(x => x.CreateAsync(It.Is<OmbiUser>(x => x.UserName == "diff_username" && x.Email == "diff_email" && x.ProviderUserId == "diff_user_id" && x.UserType == UserType.PlexUser))) _mocker.Setup<OmbiUserManager, Task<IdentityResult>>(x => x.CreateAsync(It.Is<OmbiUser>(x => x.UserName == "diff_username" && x.Email == "diff_email" && x.ProviderUserId == "diff_user_id" && x.UserType == UserType.PlexUser)))
.ReturnsAsync(IdentityResult.Success); .ReturnsAsync(IdentityResult.Success);
_mocker.Setup<OmbiUserManager, Task<IdentityResult>>(x => x.AddToRoleAsync(It.Is<OmbiUser>(x => x.UserName == "diff_username"), It.Is<string>(x => x == OmbiRoles.Admin))) _mocker.Setup<OmbiUserManager, Task<IdentityResult>>(x => x.AddToRoleAsync(It.Is<OmbiUser>(x => x.UserName == "diff_username"), It.Is<string>(x => x == OmbiRoles.Admin)))
.ReturnsAsync(IdentityResult.Success); .ReturnsAsync(IdentityResult.Success);
_mocker.Setup<OmbiUserManager, Task<bool>>(x => x.IsInRoleAsync(It.Is<OmbiUser>(x => x.UserName == "Admin"), OmbiRoles.Admin)).ReturnsAsync(true);
await _subject.Execute(null); await _subject.Execute(null);
_mocker.Verify<OmbiUserManager>(x => x.DeleteAsync(It.Is<OmbiUser>(x => x.ProviderUserId == "PLEX_ID" && x.Email == "dupe" && x.UserName == "plex")), Times.Once); _mocker.Verify<IUserDeletionEngine>(x => x.DeleteUser(It.Is<OmbiUser>(x => x.ProviderUserId == "ADMIN_ID" && x.Email == "ADMIN@ADMIN.CO" && x.UserName == "Admin")), Times.Never);
} }
} }
} }

@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Ombi.Api.Emby; using Ombi.Api.Emby;
using Ombi.Api.Emby.Models; using Ombi.Api.Emby.Models;
using Ombi.Api.Emby.Models.Media.Tv;
using Ombi.Api.Emby.Models.Movie; using Ombi.Api.Emby.Models.Movie;
using Ombi.Core.Authentication; using Ombi.Core.Authentication;
using Ombi.Core.Settings; using Ombi.Core.Settings;
@ -18,20 +19,34 @@ namespace Ombi.Schedule.Jobs.Emby
{ {
public class EmbyPlayedSync : EmbyLibrarySync, IEmbyPlayedSync public class EmbyPlayedSync : EmbyLibrarySync, IEmbyPlayedSync
{ {
public EmbyPlayedSync(ISettingsService<EmbySettings> settings, IEmbyApiFactory api, ILogger<EmbyContentSync> logger, public EmbyPlayedSync(
IUserPlayedMovieRepository repo, INotificationHubService notification, OmbiUserManager user) : base(settings, api, logger, notification) ISettingsService<EmbySettings> settings,
IEmbyApiFactory api,
ILogger<EmbyContentSync> logger,
IUserPlayedMovieRepository movieRepo,
IUserPlayedEpisodeRepository episodeRepo,
IEmbyContentRepository contentRepo,
INotificationHubService notification,
OmbiUserManager user) : base(settings, api, logger, notification)
{ {
_userManager = user; _userManager = user;
_repo = repo; _movieRepo = movieRepo;
_contentRepo = contentRepo;
_episodeRepo = episodeRepo;
} }
private OmbiUserManager _userManager { get; } private OmbiUserManager _userManager { get; }
private readonly IUserPlayedMovieRepository _repo; private readonly IUserPlayedMovieRepository _movieRepo;
private readonly IUserPlayedEpisodeRepository _episodeRepo;
private readonly IEmbyContentRepository _contentRepo;
protected override Task ProcessTv(EmbyServers server, string parentId = default) protected async override Task ProcessTv(EmbyServers server, string parentId = default)
{ {
// TODO var allUsers = await _userManager.Users.Where(x => x.UserType == UserType.EmbyUser || x.UserType == UserType.EmbyConnectUser).ToListAsync();
return Task.CompletedTask; foreach (var user in allUsers)
{
await ProcessTvUser(server, user, parentId);
}
} }
protected async override Task ProcessMovies(EmbyServers server, string parentId = default) protected async override Task ProcessMovies(EmbyServers server, string parentId = default)
@ -65,7 +80,7 @@ namespace Ombi.Schedule.Jobs.Emby
var totalCount = movies.TotalRecordCount; var totalCount = movies.TotalRecordCount;
var processed = 0; var processed = 0;
var mediaToAdd = new HashSet<UserPlayedMovie>(); var mediaToAdd = new HashSet<UserPlayedMovie>();
while (processed < totalCount) while (processed < totalCount)
{ {
foreach (var movie in movies.Items) foreach (var movie in movies.Items)
@ -80,7 +95,7 @@ namespace Ombi.Schedule.Jobs.Emby
{ {
movies = await Api.GetMoviesPlayed(server.ApiKey, parentId, processed, AmountToTake, user.ProviderUserId, server.FullUri); movies = await Api.GetMoviesPlayed(server.ApiKey, parentId, processed, AmountToTake, user.ProviderUserId, server.FullUri);
} }
await _repo.AddRange(mediaToAdd); await _movieRepo.AddRange(mediaToAdd);
mediaToAdd.Clear(); mediaToAdd.Clear();
} }
} }
@ -98,13 +113,117 @@ namespace Ombi.Schedule.Jobs.Emby
UserId = user.Id UserId = user.Id
}; };
// Check if it exists // Check if it exists
var existingMovie = await _repo.Get(userPlayedMovie.TheMovieDbId, userPlayedMovie.UserId); var existingMovie = await _movieRepo.Get(userPlayedMovie.TheMovieDbId, userPlayedMovie.UserId);
var alreadyGoingToAdd = content.Any(x => x.TheMovieDbId == userPlayedMovie.TheMovieDbId && x.UserId == userPlayedMovie.UserId); var alreadyGoingToAdd = content.Any(x => x.TheMovieDbId == userPlayedMovie.TheMovieDbId && x.UserId == userPlayedMovie.UserId);
if (existingMovie == null && !alreadyGoingToAdd) if (existingMovie == null && !alreadyGoingToAdd)
{ {
content.Add(userPlayedMovie); content.Add(userPlayedMovie);
} }
} }
private async Task ProcessTvUser(EmbyServers server, OmbiUser user, string parentId = default)
{
EmbyItemContainer<EmbyEpisodes> episodes;
if (recentlyAdded)
{
var recentlyAddedAmountToTake = 10; // to be adjusted?
episodes = await Api.GetTvPlayed(server.ApiKey, parentId, 0, recentlyAddedAmountToTake, user.ProviderUserId, server.FullUri);
// Setting this so we don't attempt to grab more than we need
if (episodes.TotalRecordCount > recentlyAddedAmountToTake)
{
episodes.TotalRecordCount = recentlyAddedAmountToTake;
}
}
else
{
episodes = await Api.GetTvPlayed(server.ApiKey, parentId, 0, AmountToTake, user.ProviderUserId, server.FullUri);
}
var totalCount = episodes.TotalRecordCount;
var processed = 0;
var mediaToAdd = new HashSet<UserPlayedEpisode>();
while (processed < totalCount)
{
foreach (var episode in episodes.Items)
{
await ProcessTv(episode, user, mediaToAdd, server);
processed++;
}
// Get the next batch
// Recently Added should never be checked as the TotalRecords should equal the amount to take
if (!recentlyAdded)
{
episodes = await Api.GetTvPlayed(server.ApiKey, parentId, processed, AmountToTake, user.ProviderUserId, server.FullUri);
}
await _episodeRepo.AddRange(mediaToAdd);
mediaToAdd.Clear();
}
}
private async Task ProcessTv(EmbyEpisodes episode, OmbiUser user, ICollection<UserPlayedEpisode> content, EmbyServers server)
{
var parent = await _contentRepo.GetByEmbyId(episode.SeriesId);
if (parent == null)
{
_logger.LogInformation("The episode {0} does not relate to a series, so we cannot save this",
episode.Name);
return;
}
if (parent.TheMovieDbId.IsNullOrEmpty())
{
_logger.LogWarning($"Episode {episode.Name} is not linked to a TMDB series. Skipping.");
return;
}
await AddToContent(content, new UserPlayedEpisode()
{
TheMovieDbId = int.Parse(parent.TheMovieDbId),
SeasonNumber = episode.ParentIndexNumber,
EpisodeNumber = episode.IndexNumber,
UserId = user.Id
});
if (episode.IndexNumberEnd.HasValue && episode.IndexNumberEnd.Value != episode.IndexNumber)
{
int episodeNumber = episode.IndexNumber;
do
{
_logger.LogDebug($"Multiple-episode file detected. Adding episode ${episodeNumber}");
episodeNumber++;
await AddToContent(content, new UserPlayedEpisode()
{
TheMovieDbId = int.Parse(parent.TheMovieDbId),
SeasonNumber = episode.ParentIndexNumber,
EpisodeNumber = episodeNumber,
UserId = user.Id
});
} while (episodeNumber < episode.IndexNumberEnd.Value);
}
}
private async Task AddToContent(ICollection<UserPlayedEpisode> content, UserPlayedEpisode episode)
{
// Check if it exists
var existingEpisode = await _episodeRepo.Get(episode.TheMovieDbId, episode.SeasonNumber, episode.EpisodeNumber, episode.UserId);
var alreadyGoingToAdd = content.Any(x =>
x.TheMovieDbId == episode.TheMovieDbId
&& x.SeasonNumber == episode.SeasonNumber
&& x.EpisodeNumber == episode.EpisodeNumber
&& x.UserId == episode.UserId);
if (existingEpisode == null && !alreadyGoingToAdd)
{
content.Add(episode);
}
}
} }
} }

@ -21,7 +21,8 @@ namespace Ombi.Schedule.Jobs.Ombi
IPlexContentRepository plexRepo, IPlexContentRepository plexRepo,
IEmbyContentRepository embyRepo, IEmbyContentRepository embyRepo,
IJellyfinContentRepository jellyfinRepo, IJellyfinContentRepository jellyfinRepo,
IUserPlayedMovieRepository userPlayedRepo, IUserPlayedMovieRepository userPlayedMovieRepo,
IUserPlayedEpisodeRepository userPlayedEpisodeRepo,
ISettingsService<EmbySettings> embySettings, ISettingsService<EmbySettings> embySettings,
ISettingsService<JellyfinSettings> jellyfinSettings) ISettingsService<JellyfinSettings> jellyfinSettings)
{ {
@ -30,7 +31,8 @@ namespace Ombi.Schedule.Jobs.Ombi
_plexRepo = plexRepo; _plexRepo = plexRepo;
_embyRepo = embyRepo; _embyRepo = embyRepo;
_jellyfinRepo = jellyfinRepo; _jellyfinRepo = jellyfinRepo;
_userPlayedRepo = userPlayedRepo; _userPlayedMovieRepo = userPlayedMovieRepo;
_userPlayedEpisodeRepo = userPlayedEpisodeRepo;
_embySettings = embySettings; _embySettings = embySettings;
_jellyfinSettings = jellyfinSettings; _jellyfinSettings = jellyfinSettings;
_plexSettings.ClearCache(); _plexSettings.ClearCache();
@ -41,7 +43,8 @@ namespace Ombi.Schedule.Jobs.Ombi
private readonly IPlexContentRepository _plexRepo; private readonly IPlexContentRepository _plexRepo;
private readonly IEmbyContentRepository _embyRepo; private readonly IEmbyContentRepository _embyRepo;
private readonly IJellyfinContentRepository _jellyfinRepo; private readonly IJellyfinContentRepository _jellyfinRepo;
private readonly IUserPlayedMovieRepository _userPlayedRepo; private readonly IUserPlayedMovieRepository _userPlayedMovieRepo;
private readonly IUserPlayedEpisodeRepository _userPlayedEpisodeRepo;
private readonly ISettingsService<EmbySettings> _embySettings; private readonly ISettingsService<EmbySettings> _embySettings;
private readonly ISettingsService<JellyfinSettings> _jellyfinSettings; private readonly ISettingsService<JellyfinSettings> _jellyfinSettings;
@ -66,7 +69,10 @@ namespace Ombi.Schedule.Jobs.Ombi
try try
{ {
const string movieSql = "DELETE FROM UserPlayedMovie"; const string movieSql = "DELETE FROM UserPlayedMovie";
await _userPlayedRepo.ExecuteSql(movieSql); await _userPlayedMovieRepo.ExecuteSql(movieSql);
const string episodeSql = "DELETE FROM UserPlayedEpisode";
await _userPlayedEpisodeRepo.ExecuteSql(episodeSql);
} }
catch (Exception e) catch (Exception e)
{ {

@ -1,40 +0,0 @@
//using System;
//using System.Threading.Tasks;
//using Hangfire;
//namespace Ombi.Schedule.Jobs.Plex
//{
// public class PlexRecentlyAddedSync : IPlexRecentlyAddedSync
// {
// public PlexRecentlyAddedSync(IPlexContentSync sync)
// {
// _sync = sync;
// }
// private readonly IPlexContentSync _sync;
// public void Start()
// {
// BackgroundJob.Enqueue(() => _sync.CacheContent(true));
// }
// private bool _disposed;
// protected virtual void Dispose(bool disposing)
// {
// if (_disposed)
// return;
// if (disposing)
// {
// _sync?.Dispose();
// }
// _disposed = true;
// }
// public void Dispose()
// {
// Dispose(true);
// GC.SuppressFinalize(this);
// }
// }
//}

@ -7,6 +7,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Ombi.Api.Plex; using Ombi.Api.Plex;
using Ombi.Core.Authentication; using Ombi.Core.Authentication;
using Ombi.Core.Engine;
using Ombi.Core.Settings; using Ombi.Core.Settings;
using Ombi.Core.Settings.Models.External; using Ombi.Core.Settings.Models.External;
using Ombi.Helpers; using Ombi.Helpers;
@ -20,7 +21,8 @@ namespace Ombi.Schedule.Jobs.Plex
public class PlexUserImporter : IPlexUserImporter public class PlexUserImporter : IPlexUserImporter
{ {
public PlexUserImporter(IPlexApi api, OmbiUserManager um, ILogger<PlexUserImporter> log, public PlexUserImporter(IPlexApi api, OmbiUserManager um, ILogger<PlexUserImporter> log,
ISettingsService<PlexSettings> plexSettings, ISettingsService<UserManagementSettings> ums, INotificationHubService notificationHubService) ISettingsService<PlexSettings> plexSettings, ISettingsService<UserManagementSettings> ums, INotificationHubService notificationHubService,
IUserDeletionEngine userDeletionEngine)
{ {
_api = api; _api = api;
_userManager = um; _userManager = um;
@ -28,6 +30,7 @@ namespace Ombi.Schedule.Jobs.Plex
_plexSettings = plexSettings; _plexSettings = plexSettings;
_userManagementSettings = ums; _userManagementSettings = ums;
_notification = notificationHubService; _notification = notificationHubService;
_userDeletionEngine = userDeletionEngine;
_plexSettings.ClearCache(); _plexSettings.ClearCache();
_userManagementSettings.ClearCache(); _userManagementSettings.ClearCache();
} }
@ -38,7 +41,7 @@ namespace Ombi.Schedule.Jobs.Plex
private readonly ISettingsService<PlexSettings> _plexSettings; private readonly ISettingsService<PlexSettings> _plexSettings;
private readonly ISettingsService<UserManagementSettings> _userManagementSettings; private readonly ISettingsService<UserManagementSettings> _userManagementSettings;
private readonly INotificationHubService _notification; private readonly INotificationHubService _notification;
private readonly IUserDeletionEngine _userDeletionEngine;
public async Task Execute(IJobExecutionContext job) public async Task Execute(IJobExecutionContext job)
{ {
@ -68,11 +71,7 @@ namespace Ombi.Schedule.Jobs.Plex
if (userManagementSettings.ImportPlexAdmin) if (userManagementSettings.ImportPlexAdmin)
{ {
OmbiUser newOrUpdatedAdmin = await ImportAdmin(userManagementSettings, server, allUsers); await ImportAdmin(userManagementSettings, server, allUsers);
if (newOrUpdatedAdmin != null)
{
newOrUpdatedUsers.Add(newOrUpdatedAdmin);
}
} }
if (userManagementSettings.ImportPlexUsers) if (userManagementSettings.ImportPlexUsers)
{ {
@ -85,12 +84,26 @@ namespace Ombi.Schedule.Jobs.Plex
// Refresh users from updates // Refresh users from updates
allUsers = await _userManager.Users.Where(x => x.UserType == UserType.PlexUser) allUsers = await _userManager.Users.Where(x => x.UserType == UserType.PlexUser)
.ToListAsync(); .ToListAsync();
var missingUsers = allUsers
.Where(x => !newOrUpdatedUsers.Contains(x)); var missingUsers = allUsers
.Where(x => !newOrUpdatedUsers.Contains(x)).ToList();
// Don't delete any admins
for (int i = missingUsers.Count() - 1; i >= 0; i--)
{
var isAdmin = await _userManager.IsInRoleAsync(missingUsers[i], OmbiRoles.Admin);
if (!isAdmin)
{
continue;
}
missingUsers.RemoveAt(i);
}
foreach (var ombiUser in missingUsers) foreach (var ombiUser in missingUsers)
{ {
_log.LogInformation("Deleting user {0} not found in Plex Server.", ombiUser.UserName); _log.LogInformation("Deleting user {0} not found in Plex Server.", ombiUser.UserName);
await _userManager.DeleteAsync(ombiUser); await _userDeletionEngine.DeleteUser(ombiUser);
} }
} }
@ -115,14 +128,13 @@ namespace Ombi.Schedule.Jobs.Plex
} }
// Check if this Plex User already exists // Check if this Plex User already exists
// We are using the Plex USERNAME and Not the TITLE, the Title is for HOME USERS // We are using the Plex USERNAME and Not the TITLE, the Title is for HOME USERS without an account
var existingPlexUser = allUsers.FirstOrDefault(x => x.ProviderUserId == plexUser.Id); var existingPlexUser = allUsers.FirstOrDefault(x => x.ProviderUserId == plexUser.Id);
if (existingPlexUser == null) if (existingPlexUser == null)
{ {
if (!plexUser.Username.HasValue()) if (!plexUser.Username.HasValue())
{ {
_log.LogInformation("Could not create Plex user since the have no username, PlexUserId: {0}", plexUser.Id); _log.LogInformation($"Could not create user since the have no username (Probably a Home User), PlexUserId: {plexUser.Id}, Title: {plexUser.Title}");
continue; continue;
} }
@ -261,4 +273,4 @@ namespace Ombi.Schedule.Jobs.Plex
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
} }
} }

@ -11,7 +11,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Quartz" Version="3.5.0" /> <PackageReference Include="Quartz" Version="3.6.2" />
<PackageReference Include="Serilog" Version="2.12.0" /> <PackageReference Include="Serilog" Version="2.12.0" />
<PackageReference Include="SharpCompress" Version="0.32.2" /> <PackageReference Include="SharpCompress" Version="0.32.2" />
<PackageReference Include="System.Diagnostics.Process" Version="4.3.0" /> <PackageReference Include="System.Diagnostics.Process" Version="4.3.0" />

@ -11,7 +11,7 @@
<PackageReference Include="nunit" Version="3.13.3" /> <PackageReference Include="nunit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" /> <PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="6.0.0" /> <PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

@ -12,7 +12,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Quartz" Version="3.5.0" /> <PackageReference Include="Quartz" Version="3.6.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
</ItemGroup> </ItemGroup>

@ -20,7 +20,6 @@ namespace Ombi.Settings.Settings.Models
public static class FeatureNames public static class FeatureNames
{ {
public const string Movie4KRequests = nameof(Movie4KRequests); public const string Movie4KRequests = nameof(Movie4KRequests);
public const string OldTrendingSource = nameof(OldTrendingSource);
public const string PlayedSync = nameof(PlayedSync); public const string PlayedSync = nameof(PlayedSync);
} }
} }

@ -42,6 +42,7 @@ namespace Ombi.Store.Context
public DbSet<SickRageCache> SickRageCache { get; set; } public DbSet<SickRageCache> SickRageCache { get; set; }
public DbSet<SickRageEpisodeCache> SickRageEpisodeCache { get; set; } public DbSet<SickRageEpisodeCache> SickRageEpisodeCache { get; set; }
public DbSet<UserPlayedMovie> UserPlayedMovie { get; set; } public DbSet<UserPlayedMovie> UserPlayedMovie { get; set; }
public DbSet<UserPlayedEpisode> UserPlayedEpisode { get; set; }
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {

@ -59,6 +59,9 @@ namespace Ombi.Store.Entities.Requests
return string.Empty; return string.Empty;
} }
} }
[NotMapped]
public int RequestedUserPlayedProgress { get; set; }
} }
public enum SeriesType public enum SeriesType

@ -0,0 +1,10 @@
namespace Ombi.Store.Entities
{
public class UserPlayedEpisode : Entity
{
public int TheMovieDbId { get; set; }
public int SeasonNumber { get; set; }
public int EpisodeNumber { get; set; }
public string UserId { get; set; }
}
}

@ -0,0 +1,589 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Ombi.Store.Context.MySql;
#nullable disable
namespace Ombi.Store.Migrations.ExternalMySql
{
[DbContext(typeof(ExternalMySqlContext))]
[Migration("20230515182204_MovieEpisodePlayed")]
partial class MovieEpisodePlayed
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "6.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("Ombi.Store.Entities.CouchPotatoCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int>("TheMovieDbId")
.HasColumnType("int");
b.HasKey("Id");
b.ToTable("CouchPotatoCache");
});
modelBuilder.Entity("Ombi.Store.Entities.EmbyContent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<string>("EmbyId")
.IsRequired()
.HasColumnType("varchar(255)");
b.Property<bool>("Has4K")
.HasColumnType("tinyint(1)");
b.Property<string>("ImdbId")
.HasColumnType("longtext");
b.Property<string>("ProviderId")
.HasColumnType("longtext");
b.Property<string>("Quality")
.HasColumnType("longtext");
b.Property<string>("TheMovieDbId")
.HasColumnType("longtext");
b.Property<string>("Title")
.HasColumnType("longtext");
b.Property<string>("TvDbId")
.HasColumnType("longtext");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<string>("Url")
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("EmbyContent");
});
modelBuilder.Entity("Ombi.Store.Entities.EmbyEpisode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<string>("EmbyId")
.HasColumnType("longtext");
b.Property<int>("EpisodeNumber")
.HasColumnType("int");
b.Property<string>("ImdbId")
.HasColumnType("longtext");
b.Property<string>("ParentId")
.HasColumnType("varchar(255)");
b.Property<string>("ProviderId")
.HasColumnType("longtext");
b.Property<int>("SeasonNumber")
.HasColumnType("int");
b.Property<string>("TheMovieDbId")
.HasColumnType("longtext");
b.Property<string>("Title")
.HasColumnType("longtext");
b.Property<string>("TvDbId")
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("EmbyEpisode");
});
modelBuilder.Entity("Ombi.Store.Entities.JellyfinContent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("Has4K")
.HasColumnType("tinyint(1)");
b.Property<string>("ImdbId")
.HasColumnType("longtext");
b.Property<string>("JellyfinId")
.IsRequired()
.HasColumnType("varchar(255)");
b.Property<string>("ProviderId")
.HasColumnType("longtext");
b.Property<string>("Quality")
.HasColumnType("longtext");
b.Property<string>("TheMovieDbId")
.HasColumnType("longtext");
b.Property<string>("Title")
.HasColumnType("longtext");
b.Property<string>("TvDbId")
.HasColumnType("longtext");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<string>("Url")
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("JellyfinContent");
});
modelBuilder.Entity("Ombi.Store.Entities.JellyfinEpisode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<int>("EpisodeNumber")
.HasColumnType("int");
b.Property<string>("ImdbId")
.HasColumnType("longtext");
b.Property<string>("JellyfinId")
.HasColumnType("longtext");
b.Property<string>("ParentId")
.HasColumnType("varchar(255)");
b.Property<string>("ProviderId")
.HasColumnType("longtext");
b.Property<int>("SeasonNumber")
.HasColumnType("int");
b.Property<string>("TheMovieDbId")
.HasColumnType("longtext");
b.Property<string>("Title")
.HasColumnType("longtext");
b.Property<string>("TvDbId")
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("JellyfinEpisode");
});
modelBuilder.Entity("Ombi.Store.Entities.LidarrAlbumCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<int>("ArtistId")
.HasColumnType("int");
b.Property<string>("ForeignAlbumId")
.HasColumnType("longtext");
b.Property<bool>("Monitored")
.HasColumnType("tinyint(1)");
b.Property<decimal>("PercentOfTracks")
.HasColumnType("decimal(65,30)");
b.Property<DateTime>("ReleaseDate")
.HasColumnType("datetime(6)");
b.Property<string>("Title")
.HasColumnType("longtext");
b.Property<int>("TrackCount")
.HasColumnType("int");
b.HasKey("Id");
b.ToTable("LidarrAlbumCache");
});
modelBuilder.Entity("Ombi.Store.Entities.LidarrArtistCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int>("ArtistId")
.HasColumnType("int");
b.Property<string>("ArtistName")
.HasColumnType("longtext");
b.Property<string>("ForeignArtistId")
.HasColumnType("longtext");
b.Property<bool>("Monitored")
.HasColumnType("tinyint(1)");
b.HasKey("Id");
b.ToTable("LidarrArtistCache");
});
modelBuilder.Entity("Ombi.Store.Entities.PlexEpisode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int>("EpisodeNumber")
.HasColumnType("int");
b.Property<string>("GrandparentKey")
.HasColumnType("varchar(255)");
b.Property<string>("Key")
.HasColumnType("longtext");
b.Property<string>("ParentKey")
.HasColumnType("longtext");
b.Property<int>("SeasonNumber")
.HasColumnType("int");
b.Property<string>("Title")
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("GrandparentKey");
b.ToTable("PlexEpisode");
});
modelBuilder.Entity("Ombi.Store.Entities.PlexSeasonsContent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("ParentKey")
.HasColumnType("longtext");
b.Property<string>("PlexContentId")
.HasColumnType("longtext");
b.Property<int?>("PlexServerContentId")
.HasColumnType("int");
b.Property<string>("SeasonKey")
.HasColumnType("longtext");
b.Property<int>("SeasonNumber")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("PlexServerContentId");
b.ToTable("PlexSeasonsContent");
});
modelBuilder.Entity("Ombi.Store.Entities.PlexServerContent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("Has4K")
.HasColumnType("tinyint(1)");
b.Property<string>("ImdbId")
.HasColumnType("longtext");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("varchar(255)");
b.Property<string>("Quality")
.HasColumnType("longtext");
b.Property<string>("ReleaseYear")
.HasColumnType("longtext");
b.Property<int?>("RequestId")
.HasColumnType("int");
b.Property<string>("TheMovieDbId")
.HasColumnType("longtext");
b.Property<string>("Title")
.HasColumnType("longtext");
b.Property<string>("TvDbId")
.HasColumnType("longtext");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<string>("Url")
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("PlexServerContent");
});
modelBuilder.Entity("Ombi.Store.Entities.PlexWatchlistHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("TmdbId")
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("PlexWatchlistHistory");
});
modelBuilder.Entity("Ombi.Store.Entities.RadarrCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<bool>("Has4K")
.HasColumnType("tinyint(1)");
b.Property<bool>("HasFile")
.HasColumnType("tinyint(1)");
b.Property<bool>("HasRegular")
.HasColumnType("tinyint(1)");
b.Property<int>("TheMovieDbId")
.HasColumnType("int");
b.HasKey("Id");
b.ToTable("RadarrCache");
});
modelBuilder.Entity("Ombi.Store.Entities.SickRageCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int>("TvDbId")
.HasColumnType("int");
b.HasKey("Id");
b.ToTable("SickRageCache");
});
modelBuilder.Entity("Ombi.Store.Entities.SickRageEpisodeCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int>("EpisodeNumber")
.HasColumnType("int");
b.Property<int>("SeasonNumber")
.HasColumnType("int");
b.Property<int>("TvDbId")
.HasColumnType("int");
b.HasKey("Id");
b.ToTable("SickRageEpisodeCache");
});
modelBuilder.Entity("Ombi.Store.Entities.SonarrCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int>("TheMovieDbId")
.HasColumnType("int");
b.Property<int>("TvDbId")
.HasColumnType("int");
b.HasKey("Id");
b.ToTable("SonarrCache");
});
modelBuilder.Entity("Ombi.Store.Entities.SonarrEpisodeCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int>("EpisodeNumber")
.HasColumnType("int");
b.Property<bool>("HasFile")
.HasColumnType("tinyint(1)");
b.Property<int>("MovieDbId")
.HasColumnType("int");
b.Property<int>("SeasonNumber")
.HasColumnType("int");
b.Property<int>("TvDbId")
.HasColumnType("int");
b.HasKey("Id");
b.ToTable("SonarrEpisodeCache");
});
modelBuilder.Entity("Ombi.Store.Entities.UserPlayedEpisode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int>("EpisodeNumber")
.HasColumnType("int");
b.Property<int>("SeasonNumber")
.HasColumnType("int");
b.Property<int>("TheMovieDbId")
.HasColumnType("int");
b.Property<string>("UserId")
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("UserPlayedEpisode");
});
modelBuilder.Entity("Ombi.Store.Entities.UserPlayedMovie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int>("TheMovieDbId")
.HasColumnType("int");
b.Property<string>("UserId")
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("UserPlayedMovie");
});
modelBuilder.Entity("Ombi.Store.Entities.EmbyEpisode", b =>
{
b.HasOne("Ombi.Store.Entities.EmbyContent", "Series")
.WithMany("Episodes")
.HasForeignKey("ParentId")
.HasPrincipalKey("EmbyId");
b.Navigation("Series");
});
modelBuilder.Entity("Ombi.Store.Entities.JellyfinEpisode", b =>
{
b.HasOne("Ombi.Store.Entities.JellyfinContent", "Series")
.WithMany("Episodes")
.HasForeignKey("ParentId")
.HasPrincipalKey("JellyfinId");
b.Navigation("Series");
});
modelBuilder.Entity("Ombi.Store.Entities.PlexEpisode", b =>
{
b.HasOne("Ombi.Store.Entities.PlexServerContent", "Series")
.WithMany("Episodes")
.HasForeignKey("GrandparentKey")
.HasPrincipalKey("Key");
b.Navigation("Series");
});
modelBuilder.Entity("Ombi.Store.Entities.PlexSeasonsContent", b =>
{
b.HasOne("Ombi.Store.Entities.PlexServerContent", null)
.WithMany("Seasons")
.HasForeignKey("PlexServerContentId");
});
modelBuilder.Entity("Ombi.Store.Entities.EmbyContent", b =>
{
b.Navigation("Episodes");
});
modelBuilder.Entity("Ombi.Store.Entities.JellyfinContent", b =>
{
b.Navigation("Episodes");
});
modelBuilder.Entity("Ombi.Store.Entities.PlexServerContent", b =>
{
b.Navigation("Episodes");
b.Navigation("Seasons");
});
#pragma warning restore 612, 618
}
}
}

@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Ombi.Store.Migrations.ExternalMySql
{
public partial class MovieEpisodePlayed : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserPlayedEpisode",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
TheMovieDbId = table.Column<int>(type: "int", nullable: false),
SeasonNumber = table.Column<int>(type: "int", nullable: false),
EpisodeNumber = table.Column<int>(type: "int", nullable: false),
UserId = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_UserPlayedEpisode", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserPlayedEpisode");
}
}
}

@ -488,6 +488,29 @@ namespace Ombi.Store.Migrations.ExternalMySql
b.ToTable("SonarrEpisodeCache"); b.ToTable("SonarrEpisodeCache");
}); });
modelBuilder.Entity("Ombi.Store.Entities.UserPlayedEpisode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int>("EpisodeNumber")
.HasColumnType("int");
b.Property<int>("SeasonNumber")
.HasColumnType("int");
b.Property<int>("TheMovieDbId")
.HasColumnType("int");
b.Property<string>("UserId")
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("UserPlayedEpisode");
});
modelBuilder.Entity("Ombi.Store.Entities.UserPlayedMovie", b => modelBuilder.Entity("Ombi.Store.Entities.UserPlayedMovie", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")

@ -0,0 +1,587 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Ombi.Store.Context.Sqlite;
#nullable disable
namespace Ombi.Store.Migrations.ExternalSqlite
{
[DbContext(typeof(ExternalSqliteContext))]
[Migration("20230515161757_EpisodeUserPlayed")]
partial class EpisodeUserPlayed
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.9");
modelBuilder.Entity("Ombi.Store.Entities.CouchPotatoCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("TheMovieDbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("CouchPotatoCache");
});
modelBuilder.Entity("Ombi.Store.Entities.EmbyContent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AddedAt")
.HasColumnType("TEXT");
b.Property<string>("EmbyId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Has4K")
.HasColumnType("INTEGER");
b.Property<string>("ImdbId")
.HasColumnType("TEXT");
b.Property<string>("ProviderId")
.HasColumnType("TEXT");
b.Property<string>("Quality")
.HasColumnType("TEXT");
b.Property<string>("TheMovieDbId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<string>("TvDbId")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<string>("Url")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("EmbyContent");
});
modelBuilder.Entity("Ombi.Store.Entities.EmbyEpisode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AddedAt")
.HasColumnType("TEXT");
b.Property<string>("EmbyId")
.HasColumnType("TEXT");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("ImdbId")
.HasColumnType("TEXT");
b.Property<string>("ParentId")
.HasColumnType("TEXT");
b.Property<string>("ProviderId")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<string>("TheMovieDbId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<string>("TvDbId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("EmbyEpisode");
});
modelBuilder.Entity("Ombi.Store.Entities.JellyfinContent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AddedAt")
.HasColumnType("TEXT");
b.Property<bool>("Has4K")
.HasColumnType("INTEGER");
b.Property<string>("ImdbId")
.HasColumnType("TEXT");
b.Property<string>("JellyfinId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ProviderId")
.HasColumnType("TEXT");
b.Property<string>("Quality")
.HasColumnType("TEXT");
b.Property<string>("TheMovieDbId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<string>("TvDbId")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<string>("Url")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("JellyfinContent");
});
modelBuilder.Entity("Ombi.Store.Entities.JellyfinEpisode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AddedAt")
.HasColumnType("TEXT");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("ImdbId")
.HasColumnType("TEXT");
b.Property<string>("JellyfinId")
.HasColumnType("TEXT");
b.Property<string>("ParentId")
.HasColumnType("TEXT");
b.Property<string>("ProviderId")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<string>("TheMovieDbId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<string>("TvDbId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("JellyfinEpisode");
});
modelBuilder.Entity("Ombi.Store.Entities.LidarrAlbumCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AddedAt")
.HasColumnType("TEXT");
b.Property<int>("ArtistId")
.HasColumnType("INTEGER");
b.Property<string>("ForeignAlbumId")
.HasColumnType("TEXT");
b.Property<bool>("Monitored")
.HasColumnType("INTEGER");
b.Property<decimal>("PercentOfTracks")
.HasColumnType("TEXT");
b.Property<DateTime>("ReleaseDate")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int>("TrackCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("LidarrAlbumCache");
});
modelBuilder.Entity("Ombi.Store.Entities.LidarrArtistCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ArtistId")
.HasColumnType("INTEGER");
b.Property<string>("ArtistName")
.HasColumnType("TEXT");
b.Property<string>("ForeignArtistId")
.HasColumnType("TEXT");
b.Property<bool>("Monitored")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("LidarrArtistCache");
});
modelBuilder.Entity("Ombi.Store.Entities.PlexEpisode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("GrandparentKey")
.HasColumnType("TEXT");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<string>("ParentKey")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GrandparentKey");
b.ToTable("PlexEpisode");
});
modelBuilder.Entity("Ombi.Store.Entities.PlexSeasonsContent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ParentKey")
.HasColumnType("TEXT");
b.Property<string>("PlexContentId")
.HasColumnType("TEXT");
b.Property<int?>("PlexServerContentId")
.HasColumnType("INTEGER");
b.Property<string>("SeasonKey")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlexServerContentId");
b.ToTable("PlexSeasonsContent");
});
modelBuilder.Entity("Ombi.Store.Entities.PlexServerContent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AddedAt")
.HasColumnType("TEXT");
b.Property<bool>("Has4K")
.HasColumnType("INTEGER");
b.Property<string>("ImdbId")
.HasColumnType("TEXT");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Quality")
.HasColumnType("TEXT");
b.Property<string>("ReleaseYear")
.HasColumnType("TEXT");
b.Property<int?>("RequestId")
.HasColumnType("INTEGER");
b.Property<string>("TheMovieDbId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<string>("TvDbId")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<string>("Url")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PlexServerContent");
});
modelBuilder.Entity("Ombi.Store.Entities.PlexWatchlistHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("TmdbId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PlexWatchlistHistory");
});
modelBuilder.Entity("Ombi.Store.Entities.RadarrCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Has4K")
.HasColumnType("INTEGER");
b.Property<bool>("HasFile")
.HasColumnType("INTEGER");
b.Property<bool>("HasRegular")
.HasColumnType("INTEGER");
b.Property<int>("TheMovieDbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("RadarrCache");
});
modelBuilder.Entity("Ombi.Store.Entities.SickRageCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("TvDbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("SickRageCache");
});
modelBuilder.Entity("Ombi.Store.Entities.SickRageEpisodeCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<int>("TvDbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("SickRageEpisodeCache");
});
modelBuilder.Entity("Ombi.Store.Entities.SonarrCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("TheMovieDbId")
.HasColumnType("INTEGER");
b.Property<int>("TvDbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("SonarrCache");
});
modelBuilder.Entity("Ombi.Store.Entities.SonarrEpisodeCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<bool>("HasFile")
.HasColumnType("INTEGER");
b.Property<int>("MovieDbId")
.HasColumnType("INTEGER");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<int>("TvDbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("SonarrEpisodeCache");
});
modelBuilder.Entity("Ombi.Store.Entities.UserPlayedEpisode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<int>("TheMovieDbId")
.HasColumnType("INTEGER");
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("UserPlayedEpisode");
});
modelBuilder.Entity("Ombi.Store.Entities.UserPlayedMovie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("TheMovieDbId")
.HasColumnType("INTEGER");
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("UserPlayedMovie");
});
modelBuilder.Entity("Ombi.Store.Entities.EmbyEpisode", b =>
{
b.HasOne("Ombi.Store.Entities.EmbyContent", "Series")
.WithMany("Episodes")
.HasForeignKey("ParentId")
.HasPrincipalKey("EmbyId");
b.Navigation("Series");
});
modelBuilder.Entity("Ombi.Store.Entities.JellyfinEpisode", b =>
{
b.HasOne("Ombi.Store.Entities.JellyfinContent", "Series")
.WithMany("Episodes")
.HasForeignKey("ParentId")
.HasPrincipalKey("JellyfinId");
b.Navigation("Series");
});
modelBuilder.Entity("Ombi.Store.Entities.PlexEpisode", b =>
{
b.HasOne("Ombi.Store.Entities.PlexServerContent", "Series")
.WithMany("Episodes")
.HasForeignKey("GrandparentKey")
.HasPrincipalKey("Key");
b.Navigation("Series");
});
modelBuilder.Entity("Ombi.Store.Entities.PlexSeasonsContent", b =>
{
b.HasOne("Ombi.Store.Entities.PlexServerContent", null)
.WithMany("Seasons")
.HasForeignKey("PlexServerContentId");
});
modelBuilder.Entity("Ombi.Store.Entities.EmbyContent", b =>
{
b.Navigation("Episodes");
});
modelBuilder.Entity("Ombi.Store.Entities.JellyfinContent", b =>
{
b.Navigation("Episodes");
});
modelBuilder.Entity("Ombi.Store.Entities.PlexServerContent", b =>
{
b.Navigation("Episodes");
b.Navigation("Seasons");
});
#pragma warning restore 612, 618
}
}
}

@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Ombi.Store.Migrations.ExternalSqlite
{
public partial class EpisodeUserPlayed : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserPlayedEpisode",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
TheMovieDbId = table.Column<int>(type: "INTEGER", nullable: false),
SeasonNumber = table.Column<int>(type: "INTEGER", nullable: false),
EpisodeNumber = table.Column<int>(type: "INTEGER", nullable: false),
UserId = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserPlayedEpisode", x => x.Id);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserPlayedEpisode");
}
}
}

@ -486,6 +486,29 @@ namespace Ombi.Store.Migrations.ExternalSqlite
b.ToTable("SonarrEpisodeCache"); b.ToTable("SonarrEpisodeCache");
}); });
modelBuilder.Entity("Ombi.Store.Entities.UserPlayedEpisode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<int>("TheMovieDbId")
.HasColumnType("INTEGER");
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("UserPlayedEpisode");
});
modelBuilder.Entity("Ombi.Store.Entities.UserPlayedMovie", b => modelBuilder.Entity("Ombi.Store.Entities.UserPlayedMovie", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")

@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Ombi.Helpers;
#nullable disable
namespace Ombi.Store.Migrations.OmbiMySql
{
public partial class Approve4KMovie : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertRoleMySql(OmbiRoles.AutoApprove4KMovie);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

@ -16,7 +16,7 @@ namespace Ombi.Store.Migrations.OmbiMySql
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "6.0.0") .HasAnnotation("ProductVersion", "6.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 64); .HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>

@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Ombi.Helpers;
#nullable disable
namespace Ombi.Store.Migrations.OmbiSqlite
{
public partial class Approve4KMovie : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertRole(OmbiRoles.AutoApprove4KMovie);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

@ -15,7 +15,7 @@ namespace Ombi.Store.Migrations.OmbiSqlite
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); modelBuilder.HasAnnotation("ProductVersion", "6.0.9");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{ {

@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Ombi.Store.Entities;
namespace Ombi.Store.Repository
{
public interface IUserPlayedEpisodeRepository : IExternalRepository<UserPlayedEpisode>
{
Task<UserPlayedEpisode> Get(int theMovieDbId, int seasonNumber, int episodeNumber, string userId);
}
}

@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using Ombi.Store.Context;
using Ombi.Store.Entities;
namespace Ombi.Store.Repository
{
public class UserPlayedEpisodeRepository : ExternalRepository<UserPlayedEpisode>, IUserPlayedEpisodeRepository
{
protected ExternalContext Db { get; }
public UserPlayedEpisodeRepository(ExternalContext db) : base(db)
{
Db = db;
}
public async Task<UserPlayedEpisode> Get(int theMovieDbId, int seasonNumber, int episodeNumber, string userId)
{
return await Db.UserPlayedEpisode.FirstOrDefaultAsync(x => x.TheMovieDbId == theMovieDbId && x.SeasonNumber == seasonNumber && x.EpisodeNumber == episodeNumber && x.UserId == userId);
}
}
}

@ -7,7 +7,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.2.0" />
<PackageReference Include="Moq" Version="4.18.2" /> <PackageReference Include="Moq" Version="4.18.2" />
<PackageReference Include="MockQueryable.Moq" Version="6.0.1" /> <PackageReference Include="MockQueryable.Moq" Version="6.0.1" />
</ItemGroup> </ItemGroup>

@ -1,26 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<Configurations>Debug;Release;NonUiBuild</Configurations> <Configurations>Debug;Release;NonUiBuild</Configurations>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="6.0.9" /> <PackageReference Include="Microsoft.AspNetCore.TestHost" Version="6.0.9" />
<PackageReference Include="Moq" Version="4.18.2" /> <PackageReference Include="Moq" Version="4.18.2" />
<PackageReference Include="Moq.AutoMock" Version="3.4.0" /> <PackageReference Include="Moq.AutoMock" Version="3.4.0" />
<PackageReference Include="Nunit" Version="3.13.3" /> <PackageReference Include="Nunit" Version="3.13.3" />
<PackageReference Include="Hangfire" Version="1.7.31" /> <PackageReference Include="NUnit.ConsoleRunner" Version="3.15.2" />
<PackageReference Include="NUnit.ConsoleRunner" Version="3.15.2" /> <PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="6.0.0" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="6.0.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" /> <packagereference Include="Microsoft.NET.Test.Sdk" Version="17.6.2"></packagereference>
<packagereference Include="Microsoft.NET.Test.Sdk" Version="17.3.2"></packagereference> </ItemGroup>
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Ombi.Test.Common\Ombi.Test.Common.csproj" /> <ProjectReference Include="..\Ombi.Test.Common\Ombi.Test.Common.csproj" />
<ProjectReference Include="..\Ombi\Ombi.csproj" /> <ProjectReference Include="..\Ombi\Ombi.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

@ -27,5 +27,6 @@
"plex", "plex",
"wizard" "wizard"
], ],
"rpc.enabled": true "rpc.enabled": true,
"dotnet.defaultSolution": "Ombi.sln"
} }

@ -13,33 +13,31 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^15.0.4", "@angular/animations": "^15.2.9",
"@angular/cdk": "^14.2.7", "@angular/cdk": "^14.2.7",
"@angular/common": "^15.0.4", "@angular/common": "^15.2.9",
"@angular/compiler": "^15.0.4", "@angular/compiler": "^15.2.9",
"@angular/core": "^15.0.4", "@angular/core": "^15.2.9",
"@angular/forms": "^15.0.4", "@angular/forms": "^15.2.9",
"@angular/localize": "^15.0.4",
"@angular/material": "^14.2.7", "@angular/material": "^14.2.7",
"@angular/platform-browser": "^15.0.4", "@angular/platform-browser": "^15.2.9",
"@angular/platform-browser-dynamic": "^15.0.4", "@angular/platform-browser-dynamic": "^15.2.9",
"@angular/platform-server": "^15.0.4", "@angular/platform-server": "^15.2.9",
"@angular/router": "^15.0.4", "@angular/router": "^15.2.9",
"@angularclass/hmr": "^3.0.0", "@angularclass/hmr": "^3.0.0",
"@auth0/angular-jwt": "^5.0.2", "@auth0/angular-jwt": "^5.0.2",
"@fortawesome/fontawesome-free": "^6.0.0", "@fortawesome/fontawesome-free": "^6.4.0",
"@microsoft/signalr": "^6.0.7", "@microsoft/signalr": "^6.0.20",
"@ngx-translate/core": "^14.0.0", "@ngx-translate/core": "^14.0.0",
"@ngx-translate/http-loader": "^7.0.0", "@ngx-translate/http-loader": "^7.0.0",
"@ngxs/devtools-plugin": "3.7.3", "@ngxs/devtools-plugin": "3.8.1",
"@ngxs/store": "3.7.3", "@ngxs/store": "3.8.1",
"@types/jquery": "^3.5.14", "@types/jquery": "^3.5.14",
"@yellowspot/ng-truncate": "^2.0.0", "@yellowspot/ng-truncate": "^2.0.0",
"angular-router-loader": "^0.8.5",
"angularx-qrcode": "^15.0.0", "angularx-qrcode": "^15.0.0",
"bootstrap": "^4.2.1", "bootstrap": "^4.2.1",
"core-js": "^2.5.4", "core-js": "^2.5.4",
"jquery": "3.6.1", "jquery": "3.7.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.29.1", "moment": "^2.29.1",
"ng2-cookies": "^1.0.12", "ng2-cookies": "^1.0.12",
@ -49,11 +47,10 @@
"ngx-order-pipe": "^2.2.0", "ngx-order-pipe": "^2.2.0",
"popper.js": "^1.14.3", "popper.js": "^1.14.3",
"primeicons": "^6.0.1", "primeicons": "^6.0.1",
"primeng": "^15.0.0-rc.1", "primeng": "^15.4.1",
"rxjs": "^7.5.4", "rxjs": "^7.5.4",
"ts-md5": "^1.2.7", "ts-md5": "^1.2.7",
"tslint-angular": "^1.1.2", "zone.js": "~0.13.0"
"zone.js": "~0.11.4"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^15.0.2", "@angular-devkit/build-angular": "^15.0.2",

@ -46,6 +46,7 @@ import { MatNativeDateModule } from '@angular/material/core';
import { MatPaginatorI18n } from "./localization/MatPaginatorI18n"; import { MatPaginatorI18n } from "./localization/MatPaginatorI18n";
import { MatPaginatorIntl } from "@angular/material/paginator"; import { MatPaginatorIntl } from "@angular/material/paginator";
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSidenavModule } from '@angular/material/sidenav'; import { MatSidenavModule } from '@angular/material/sidenav';
import { MatSlideToggleModule } from "@angular/material/slide-toggle"; import { MatSlideToggleModule } from "@angular/material/slide-toggle";
import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBarModule } from '@angular/material/snack-bar';
@ -150,6 +151,7 @@ export function JwtTokenGetter() {
OverlayModule, OverlayModule,
MatCheckboxModule, MatCheckboxModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
MatProgressBarModule,
JwtModule.forRoot({ JwtModule.forRoot({
config: { config: {
tokenGetter: JwtTokenGetter, tokenGetter: JwtTokenGetter,

@ -132,6 +132,7 @@ export interface ITvRequests {
background: any; background: any;
totalSeasons: number; totalSeasons: number;
tvDbId: number; // NO LONGER USED tvDbId: number; // NO LONGER USED
requestedUserPlayedProgress: number;
open: boolean; // THIS IS FOR THE UI open: boolean; // THIS IS FOR THE UI

@ -5,13 +5,13 @@
<!-- <div class="row"> --> <!-- <div class="row"> -->
<div class="row justify-content-md-center top-spacing"> <div class="row justify-content-md-center top-spacing">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button type="button" (click)="switchFilter(RequestFilter.All)" [attr.color]="currentFilter === RequestFilter.All ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.All ? 'mat-accent' : 'mat-primary'" mat-raised-button class="btn grow">{{'Requests.AllRequests' | translate}}</button> <button type="button" (click)="switchFilter(RequestFilter.All)" [attr.color]="currentFilter === RequestFilter.All ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.All ? 'mat-accent' : 'mat-primary'" mat-raised-button class="grow">{{'Requests.AllRequests' | translate}}</button>
<button type="button" (click)="switchFilter(RequestFilter.Pending)" [attr.color]="currentFilter === RequestFilter.Pending ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Pending ? 'mat-accent' : 'mat-primary'" mat-raised-button class="btn grow">{{'Requests.PendingRequests' | translate}}</button> <button type="button" (click)="switchFilter(RequestFilter.Pending)" [attr.color]="currentFilter === RequestFilter.Pending ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Pending ? 'mat-accent' : 'mat-primary'" mat-raised-button class="grow">{{'Requests.PendingRequests' | translate}}</button>
<button type="button" (click)="switchFilter(RequestFilter.Processing)" [attr.color]="currentFilter === RequestFilter.Processing ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Processing ? 'mat-accent' : 'mat-primary'" mat-raised-button <button type="button" (click)="switchFilter(RequestFilter.Processing)" [attr.color]="currentFilter === RequestFilter.Processing ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Processing ? 'mat-accent' : 'mat-primary'" mat-raised-button
class="btn grow">{{'Requests.ProcessingRequests' | translate}}</button> class="grow">{{'Requests.ProcessingRequests' | translate}}</button>
<button type="button" (click)="switchFilter(RequestFilter.Available)" [attr.color]="currentFilter === RequestFilter.Available ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Available ? 'mat-accent' : 'mat-primary'" mat-raised-button <button type="button" (click)="switchFilter(RequestFilter.Available)" [attr.color]="currentFilter === RequestFilter.Available ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Available ? 'mat-accent' : 'mat-primary'" mat-raised-button
class="btn grow">{{'Requests.AvailableRequests' | translate}}</button> class="grow">{{'Requests.AvailableRequests' | translate}}</button>
<button type="button" (click)="switchFilter(RequestFilter.Denied)" [attr.color]="currentFilter === RequestFilter.Denied ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Denied ? 'mat-accent' : 'mat-primary'" mat-raised-button class="btn grow">{{'Requests.DeniedRequests' | translate}}</button> <button type="button" (click)="switchFilter(RequestFilter.Denied)" [attr.color]="currentFilter === RequestFilter.Denied ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Denied ? 'mat-accent' : 'mat-primary'" mat-raised-button class="grow">{{'Requests.DeniedRequests' | translate}}</button>
</div> </div>
</div> </div>
@ -69,4 +69,4 @@
</table> </table>
<mat-paginator [length]="resultsLength" [pageSize]="gridCount"></mat-paginator> <mat-paginator [length]="resultsLength" [pageSize]="gridCount"></mat-paginator>
</div> </div>

@ -5,13 +5,13 @@
<!-- <div class="row"> --> <!-- <div class="row"> -->
<div class="row justify-content-md-center top-spacing"> <div class="row justify-content-md-center top-spacing">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button type="button" id="filterAll" (click)="switchFilter(RequestFilter.All)" [attr.color]="currentFilter === RequestFilter.All ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.All ? 'mat-accent' : 'mat-primary'" mat-raised-button class="btn grow">{{'Requests.AllRequests' | translate}}</button> <button type="button" id="filterAll" (click)="switchFilter(RequestFilter.All)" [attr.color]="currentFilter === RequestFilter.All ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.All ? 'mat-accent' : 'mat-primary'" mat-raised-button class="grow">{{'Requests.AllRequests' | translate}}</button>
<button type="button" id="filterPending" (click)="switchFilter(RequestFilter.Pending)" [attr.color]="currentFilter === RequestFilter.Pending ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Pending ? 'mat-accent' : 'mat-primary'" mat-raised-button class="btn grow">{{'Requests.PendingRequests' | translate}}</button> <button type="button" id="filterPending" (click)="switchFilter(RequestFilter.Pending)" [attr.color]="currentFilter === RequestFilter.Pending ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Pending ? 'mat-accent' : 'mat-primary'" mat-raised-button class="grow">{{'Requests.PendingRequests' | translate}}</button>
<button type="button" id="filterProcessing" (click)="switchFilter(RequestFilter.Processing)" [attr.color]="currentFilter === RequestFilter.Processing ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Processing ? 'mat-accent' : 'mat-primary'" mat-raised-button <button type="button" id="filterProcessing" (click)="switchFilter(RequestFilter.Processing)" [attr.color]="currentFilter === RequestFilter.Processing ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Processing ? 'mat-accent' : 'mat-primary'" mat-raised-button
class="btn grow">{{'Requests.ProcessingRequests' | translate}}</button> class="grow">{{'Requests.ProcessingRequests' | translate}}</button>
<button type="button" id="filterAvailable" (click)="switchFilter(RequestFilter.Available)" [attr.color]="currentFilter === RequestFilter.Available ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Available ? 'mat-accent' : 'mat-primary'" mat-raised-button <button type="button" id="filterAvailable" (click)="switchFilter(RequestFilter.Available)" [attr.color]="currentFilter === RequestFilter.Available ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Available ? 'mat-accent' : 'mat-primary'" mat-raised-button
class="btn grow">{{'Requests.AvailableRequests' | translate}}</button> class="grow">{{'Requests.AvailableRequests' | translate}}</button>
<button type="button" id="filterDenied" (click)="switchFilter(RequestFilter.Denied)" [attr.color]="currentFilter === RequestFilter.Denied ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Denied ? 'mat-accent' : 'mat-primary'" mat-raised-button class="btn grow">{{'Requests.DeniedRequests' | translate}}</button> <button type="button" id="filterDenied" (click)="switchFilter(RequestFilter.Denied)" [attr.color]="currentFilter === RequestFilter.Denied ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Denied ? 'mat-accent' : 'mat-primary'" mat-raised-button class="grow">{{'Requests.DeniedRequests' | translate}}</button>
</div> </div>
</div> </div>

@ -5,13 +5,13 @@
<div class="row justify-content-md-center top-spacing"> <div class="row justify-content-md-center top-spacing">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button type="button" id="filterAll" (click)="switchFilter(RequestFilter.All)" [attr.color]="currentFilter === RequestFilter.All ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.All ? 'mat-accent' : 'mat-primary'" mat-raised-button class="btn grow">{{'Requests.AllRequests' | translate}}</button> <button type="button" id="filterAll" (click)="switchFilter(RequestFilter.All)" [attr.color]="currentFilter === RequestFilter.All ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.All ? 'mat-accent' : 'mat-primary'" mat-raised-button class="grow">{{'Requests.AllRequests' | translate}}</button>
<button type="button" id="filterPending" (click)="switchFilter(RequestFilter.Pending)" [attr.color]="currentFilter === RequestFilter.Pending ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Pending ? 'mat-accent' : 'mat-primary'" mat-raised-button class="btn grow">{{'Requests.PendingRequests' | translate}}</button> <button type="button" id="filterPending" (click)="switchFilter(RequestFilter.Pending)" [attr.color]="currentFilter === RequestFilter.Pending ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Pending ? 'mat-accent' : 'mat-primary'" mat-raised-button class="grow">{{'Requests.PendingRequests' | translate}}</button>
<button type="button" id="filterProcessing" (click)="switchFilter(RequestFilter.Processing)" [attr.color]="currentFilter === RequestFilter.Processing ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Processing ? 'mat-accent' : 'mat-primary'" mat-raised-button <button type="button" id="filterProcessing" (click)="switchFilter(RequestFilter.Processing)" [attr.color]="currentFilter === RequestFilter.Processing ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Processing ? 'mat-accent' : 'mat-primary'" mat-raised-button
class="btn grow">{{'Requests.ProcessingRequests' | translate}}</button> class="grow">{{'Requests.ProcessingRequests' | translate}}</button>
<button type="button" id="filterAvailable" (click)="switchFilter(RequestFilter.Available)" [attr.color]="currentFilter === RequestFilter.Available ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Available ? 'mat-accent' : 'mat-primary'" mat-raised-button <button type="button" id="filterAvailable" (click)="switchFilter(RequestFilter.Available)" [attr.color]="currentFilter === RequestFilter.Available ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Available ? 'mat-accent' : 'mat-primary'" mat-raised-button
class="btn grow">{{'Requests.AvailableRequests' | translate}}</button> class="grow">{{'Requests.AvailableRequests' | translate}}</button>
<button type="button" id="filterDenied" (click)="switchFilter(RequestFilter.Denied)" [attr.color]="currentFilter === RequestFilter.Denied ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Denied ? 'mat-accent' : 'mat-primary'" mat-raised-button class="btn grow">{{'Requests.DeniedRequests' | translate}}</button> <button type="button" id="filterDenied" (click)="switchFilter(RequestFilter.Denied)" [attr.color]="currentFilter === RequestFilter.Denied ? 'accent' : 'primary'" [ngClass]="currentFilter === RequestFilter.Denied ? 'mat-accent' : 'mat-primary'" mat-raised-button class="grow">{{'Requests.DeniedRequests' | translate}}</button>
</div> </div>
</div> </div>
@ -60,10 +60,23 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="watchedByRequestedUser">
<th
mat-header-cell
*matHeaderCellDef
disableClear
matTooltip="{{ 'Requests.WatchedProgressTooltip' | translate}}">
{{ 'Requests.Watched' | translate}}
</th>
<td mat-cell id="requestedUserPlayedProgress{{element.id}}" *matCellDef="let element">
<mat-progress-bar mode="determinate" value="{{element.requestedUserPlayedProgress}}" class="played-progress"></mat-progress-bar>
</td>
</ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef> </th> <th mat-header-cell *matHeaderCellDef> </th>
<td mat-cell *matCellDef="let element"> <td mat-cell *matCellDef="let element">
<a id="detailsButton{{element.id}}" mat-raised-button color="accent" [routerLink]="'/details/tv/' + element.parentRequest.externalProviderId">{{'Requests.Details' | translate}}</a> <a id="detailsButton{{element.id}}" mat-raised-button color="accent" [routerLink]="'/details/tv/' + element.parentRequest.externalProviderId">{{'Requests.Details' | translate}}</a>
<button id="optionsButton{{element.id}}" mat-raised-button color="warn" (click)="openOptions(element)" *ngIf="isAdmin">{{'Requests.Options' | translate}}</button> <button id="optionsButton{{element.id}}" mat-raised-button color="warn" (click)="openOptions(element)" *ngIf="isAdmin">{{'Requests.Options' | translate}}</button>
</td> </td>
</ng-container> </ng-container>

@ -0,0 +1,6 @@
@import "./styles/variables.scss";
.played-progress {
width: 5rem;
height: 1rem;
}

@ -4,6 +4,7 @@ import { Observable, merge, of as observableOf } from 'rxjs';
import { catchError, map, startWith, switchMap } from 'rxjs/operators'; import { catchError, map, startWith, switchMap } from 'rxjs/operators';
import { AuthService } from "../../../auth/auth.service"; import { AuthService } from "../../../auth/auth.service";
import { FeaturesFacade } from "../../../state/features/features.facade";
import { MatPaginator } from "@angular/material/paginator"; import { MatPaginator } from "@angular/material/paginator";
import { MatSort } from "@angular/material/sort"; import { MatSort } from "@angular/material/sort";
import { RequestFilterType } from "../../models/RequestFilterType"; import { RequestFilterType } from "../../models/RequestFilterType";
@ -13,15 +14,16 @@ import { StorageService } from "../../../shared/storage/storage-service";
@Component({ @Component({
templateUrl: "./tv-grid.component.html", templateUrl: "./tv-grid.component.html",
selector: "tv-grid", selector: "tv-grid",
styleUrls: ["../requests-list.component.scss"] styleUrls: ["../requests-list.component.scss", "tv-grid.component.scss"]
}) })
export class TvGridComponent implements OnInit, AfterViewInit { export class TvGridComponent implements OnInit, AfterViewInit {
public dataSource: IChildRequests[] = []; public dataSource: IChildRequests[] = [];
public resultsLength: number; public resultsLength: number;
public isLoadingResults = true; public isLoadingResults = true;
public displayedColumns: string[] = ['series', 'requestedBy', 'status', 'requestStatus', 'requestedDate','actions']; public displayedColumns: string[] = ['series', 'requestedBy', 'status', 'requestStatus', 'requestedDate'];
public gridCount: string = "15"; public gridCount: string = "15";
public isAdmin: boolean; public isAdmin: boolean;
public isPlayedSyncEnabled = false;
public defaultSort: string = "requestedDate"; public defaultSort: string = "requestedDate";
public defaultOrder: string = "desc"; public defaultOrder: string = "desc";
public currentFilter: RequestFilterType = RequestFilterType.All; public currentFilter: RequestFilterType = RequestFilterType.All;
@ -40,12 +42,17 @@ export class TvGridComponent implements OnInit, AfterViewInit {
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
constructor(private requestService: RequestServiceV2, private auth: AuthService, constructor(private requestService: RequestServiceV2, private auth: AuthService,
private ref: ChangeDetectorRef, private storageService: StorageService) { private ref: ChangeDetectorRef, private storageService: StorageService,
private featureFacade: FeaturesFacade) {
} }
public ngOnInit() { public ngOnInit() {
this.isAdmin = this.auth.hasRole("admin") || this.auth.hasRole("poweruser"); this.isAdmin = this.auth.hasRole("admin") || this.auth.hasRole("poweruser");
this.isPlayedSyncEnabled = this.featureFacade.isPlayedSyncEnabled();
this.addDynamicColumns();
const defaultCount = this.storageService.get(this.storageKeyGridCount); const defaultCount = this.storageService.get(this.storageKeyGridCount);
const defaultSort = this.storageService.get(this.storageKey); const defaultSort = this.storageService.get(this.storageKey);
const defaultOrder = this.storageService.get(this.storageKeyOrder); const defaultOrder = this.storageService.get(this.storageKeyOrder);
@ -64,9 +71,18 @@ export class TvGridComponent implements OnInit, AfterViewInit {
} }
} }
addDynamicColumns() {
if (this.isPlayedSyncEnabled) {
this.displayedColumns.push('watchedByRequestedUser');
}
// always put the actions column at the end
this.displayedColumns.push('actions');
}
public async ngAfterViewInit() { public async ngAfterViewInit() {
this.storageService.save(this.storageKeyGridCount, this.gridCount); this.storageService.save(this.storageKeyGridCount, this.gridCount);
this.storageService.save(this.storageKeyCurrentFilter, (+this.currentFilter).toString()); this.storageService.save(this.storageKeyCurrentFilter, (+this.currentFilter).toString());
this.paginator.showFirstLastButtons = true; this.paginator.showFirstLastButtons = true;
@ -78,7 +94,7 @@ export class TvGridComponent implements OnInit, AfterViewInit {
startWith({}), startWith({}),
switchMap((value: any) => { switchMap((value: any) => {
this.isLoadingResults = true; this.isLoadingResults = true;
if (value.active || value.direction) { if (value.active || value.direction) {
this.storageService.save(this.storageKey, value.active); this.storageService.save(this.storageKey, value.active);
this.storageService.save(this.storageKeyOrder, value.direction); this.storageService.save(this.storageKeyOrder, value.direction);
@ -103,7 +119,7 @@ export class TvGridComponent implements OnInit, AfterViewInit {
const filter = () => { this.dataSource = this.dataSource.filter((req) => { const filter = () => { this.dataSource = this.dataSource.filter((req) => {
return req.id !== request.id; return req.id !== request.id;
})}; })};
const onChange = () => { const onChange = () => {
this.ref.detectChanges(); this.ref.detectChanges();
}; };

@ -2,26 +2,35 @@
<settings-menu></settings-menu> <settings-menu></settings-menu>
<div class="small-middle-container"> <div class="small-middle-container">
<table class="table table-striped table-hover table-responsive table-condensed"> <table mat-table [dataSource]="vm">
<thead>
<tr> <ng-container matColumnDef="title">
<td>Title</td> <th mat-header-cell *matHeaderCellDef>Title</th>
<td>Type</td> <td mat-cell *matCellDef="let v">{{v.title}}</td>
<td>Retry Count</td> </ng-container>
<td>Error Description</td>
<td>Delete</td> <ng-container matColumnDef="type">
</tr> <th mat-header-cell *matHeaderCellDef>Type</th>
</thead> <td mat-cell *matCellDef="let v">{{RequestType[v.type] | humanize}}</td>
<tbody> </ng-container>
<tr *ngFor="let v of vm">
<td class="vcenter"> <ng-container matColumnDef="retryCount">
{{v.title}} <th mat-header-cell *matHeaderCellDef>Retry Count</th>
</td> <td mat-cell *matCellDef="let v">{{v.retryCount}}</td>
<td>{{RequestType[v.type] | humanize}}</td> </ng-container>
<td class="vcenter">{{v.retryCount}}</td>
<td class="vcenter"> <i [pTooltip]="v.error" class="fas fa-info-circle"></i></td> <ng-container matColumnDef="errorDescription">
<td class="vcenter"><button type="button" class="mat-focus-indicator mat-flat-button mat-button-base mat-warn" (click)="remove(v)">Remove</button></td> <th mat-header-cell *matHeaderCellDef>Error Description</th>
</tr> <td mat-cell *matCellDef="let v"><i [pTooltip]="v.error" class="fas fa-info-circle"></i></td>
</tbody> </ng-container>
</table>
<ng-container matColumnDef="deleteBtn">
<th mat-header-cell *matHeaderCellDef>Delete</th>
<td mat-cell *matCellDef="let v"><button mat-raised-button color="warn" (click)="remove(v)">Remove</button></td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columnsToDisplay"></tr>
<tr mat-row *matRowDef="let myRowData; columns: columnsToDisplay"></tr>
</table>
</div> </div>

@ -8,6 +8,7 @@ import { RequestRetryService } from "../../services";
}) })
export class FailedRequestsComponent implements OnInit { export class FailedRequestsComponent implements OnInit {
public columnsToDisplay = ["title", "type", "retryCount", "errorDescription", "deleteBtn"];
public vm: IFailedRequestsViewModel[]; public vm: IFailedRequestsViewModel[];
public RequestType = RequestType; public RequestType = RequestType;

@ -48,12 +48,11 @@
<div class="col-md-6"> <div class="col-md-6">
<div *ngIf="categories"> <div *ngIf="categories">
<div class="form-group row"> <div class="form-group row">
<div class="col-md-12">
<label for="categoryToAdd" class="control-label">Add Category</label>
</div>
<div class="col-md-9"> <div class="col-md-9">
<input type="text" [(ngModel)]="categoryToAdd.value" class="form-control form-control-custom " id="categoryToAdd" <mat-form-field appearance="outline">
name="categoryToAdd" value="{{categoryToAdd.value}}"> <mat-label>Add Category</mat-label>
<input matInput type="text" [(ngModel)]="categoryToAdd.value" id="categoryToAdd" name="categoryToAdd" value="{{categoryToAdd.value}}">
</mat-form-field>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<button mat-raised-button (click)="addCategory()">Add</button> <button mat-raised-button (click)="addCategory()">Add</button>
@ -74,4 +73,4 @@
</div> </div>
</div> </div>
</fieldset> </fieldset>
</div> </div>

@ -6,83 +6,83 @@
<form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)"> <form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)">
<small>Changes require a restart.</small><p> <small>Changes require a restart.</small><p>
<small>You can generate valid CRON Expressions here: <a href="http://www.cronmaker.com/" target="_blank">https://www.cronmaker.com/</a></small> <small>You can generate valid CRON Expressions here: <a href="http://www.cronmaker.com/" target="_blank">https://www.cronmaker.com/</a></small>
<div style="margin-top:1em;"> <div style="margin-top:1em;">
<div class="form-group cronBox"> <div class="form-group cronBox">
<mat-form-field appearance="outline" floatLabel=always> <mat-form-field appearance="outline" floatLabel=always>
<mat-label for="sonarrSync" class="control-mat-label">Sonarr Sync</mat-label> <mat-label for="sonarrSync" class="control-mat-label">Sonarr Sync</mat-label>
<input matInput type="text" [ngClass]="{'form-error': form.get('sonarrSync').hasError('required')}" id="sonarrSync" name="sonarrSync" formControlName="sonarrSync"> <input matInput type="text" [ngClass]="{'form-error': form.get('sonarrSync').hasError('required')}" id="sonarrSync" name="sonarrSync" formControlName="sonarrSync">
<small *ngIf="form.get('sonarrSync').hasError('required')" class="error-text">The Sonarr Sync is required</small></mat-form-field> <small *ngIf="form.get('sonarrSync').hasError('required')" class="error-text">The Sonarr Sync is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('sonarrSync')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('sonarrSync')?.value)">Test</button>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
<mat-form-field appearance="outline" floatLabel=always> <mat-form-field appearance="outline" floatLabel=always>
<mat-label for="sickRageSync" class="control-mat-label">SickRage Sync</mat-label> <mat-label for="sickRageSync" class="control-mat-label">SickRage Sync</mat-label>
<input type="text" matInput [ngClass]="{'form-error': form.get('sonarrSync').hasError('required')}" id="sickRageSync" name="sickRageSync" formControlName="sickRageSync"> <input type="text" matInput [ngClass]="{'form-error': form.get('sonarrSync').hasError('required')}" id="sickRageSync" name="sickRageSync" formControlName="sickRageSync">
<small *ngIf="form.get('sickRageSync').hasError('required')" class="error-text">The SickRage Sync is required</small></mat-form-field> <small *ngIf="form.get('sickRageSync').hasError('required')" class="error-text">The SickRage Sync is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('sickRageSync')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('sickRageSync')?.value)">Test</button>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
<mat-form-field appearance="outline" floatLabel=always> <mat-form-field appearance="outline" floatLabel=always>
<mat-label for="radarrSync" class="control-mat-label">Radarr Sync</mat-label> <mat-label for="radarrSync" class="control-mat-label">Radarr Sync</mat-label>
<input type="text" matInput [ngClass]="{'form-error': form.get('radarrSync').hasError('required')}" id="radarrSync" name="radarrSync" formControlName="radarrSync"> <input type="text" matInput [ngClass]="{'form-error': form.get('radarrSync').hasError('required')}" id="radarrSync" name="radarrSync" formControlName="radarrSync">
<small *ngIf="form.get('radarrSync').hasError('required')" class="error-text">The Radarr Sync is required</small></mat-form-field> <small *ngIf="form.get('radarrSync').hasError('required')" class="error-text">The Radarr Sync is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('radarrSync')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('radarrSync')?.value)">Test</button>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
<mat-form-field appearance="outline" floatLabel=always> <mat-form-field appearance="outline" floatLabel=always>
<mat-label for="lidarrArtistSync" class="control-mat-label">Lidarr Sync</mat-label> <mat-label for="lidarrArtistSync" class="control-mat-label">Lidarr Sync</mat-label>
<input type="text" matInput [ngClass]="{'form-error': form.get('lidarrArtistSync').hasError('required')}" id="lidarrArtistSync" name="lidarrArtistSync" formControlName="lidarrArtistSync"> <input type="text" matInput [ngClass]="{'form-error': form.get('lidarrArtistSync').hasError('required')}" id="lidarrArtistSync" name="lidarrArtistSync" formControlName="lidarrArtistSync">
<small *ngIf="form.get('lidarrArtistSync').hasError('required')" class="error-text">The Lidarr Sync is required</small></mat-form-field> <small *ngIf="form.get('lidarrArtistSync').hasError('required')" class="error-text">The Lidarr Sync is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('lidarrArtistSync')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('lidarrArtistSync')?.value)">Test</button>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
<mat-form-field appearance="outline" floatLabel=always> <mat-form-field appearance="outline" floatLabel=always>
<mat-label for="couchPotatoSync" class="control-mat-label">CouchPotato Sync</mat-label> <mat-label for="couchPotatoSync" class="control-mat-label">CouchPotato Sync</mat-label>
<input type="text" matInput [ngClass]="{'form-error': form.get('radarrSync').hasError('required')}" id="couchPotatoSync" name="couchPotatoSync" formControlName="couchPotatoSync"> <input type="text" matInput [ngClass]="{'form-error': form.get('radarrSync').hasError('required')}" id="couchPotatoSync" name="couchPotatoSync" formControlName="couchPotatoSync">
<small *ngIf="form.get('couchPotatoSync').hasError('required')" class="error-text">The CouchPotato Sync is required</small></mat-form-field> <small *ngIf="form.get('couchPotatoSync').hasError('required')" class="error-text">The CouchPotato Sync is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('couchPotatoSync')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('couchPotatoSync')?.value)">Test</button>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
<mat-form-field appearance="outline" floatLabel=always> <mat-form-field appearance="outline" floatLabel=always>
<mat-label for="automaticUpdater" class="control-mat-label">Automatic Update</mat-label> <mat-label for="automaticUpdater" class="control-mat-label">Automatic Update</mat-label>
<input type="text" matInput [ngClass]="{'form-error': form.get('automaticUpdater').hasError('required')}" id="automaticUpdater" name="automaticUpdater" formControlName="automaticUpdater"> <input type="text" matInput [ngClass]="{'form-error': form.get('automaticUpdater').hasError('required')}" id="automaticUpdater" name="automaticUpdater" formControlName="automaticUpdater">
<small *ngIf="form.get('automaticUpdater').hasError('required')" class="error-text">The Automatic Update is required</small></mat-form-field> <small *ngIf="form.get('automaticUpdater').hasError('required')" class="error-text">The Automatic Update is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('automaticUpdater')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('automaticUpdater')?.value)">Test</button>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
<mat-form-field appearance="outline" floatLabel=always> <mat-form-field appearance="outline" floatLabel=always>
<mat-label for="retryRequests" class="control-mat-label">Retry Failed Requests</mat-label> <mat-label for="retryRequests" class="control-mat-label">Retry Failed Requests</mat-label>
<input type="text" matInput [ngClass]="{'form-error': form.get('retryRequests').hasError('required')}" id="retryRequests" name="retryRequests" formControlName="retryRequests"> <input type="text" matInput [ngClass]="{'form-error': form.get('retryRequests').hasError('required')}" id="retryRequests" name="retryRequests" formControlName="retryRequests">
<small *ngIf="form.get('retryRequests').hasError('required')" class="error-text">The Retry Requests is required</small></mat-form-field> <small *ngIf="form.get('retryRequests').hasError('required')" class="error-text">The Retry Requests is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('retryRequests')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('retryRequests')?.value)">Test</button>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
<mat-form-field appearance="outline" floatLabel=always> <mat-form-field appearance="outline" floatLabel=always>
<mat-label for="plexContentSync" class="control-mat-label">Plex Sync</mat-label> <mat-label for="plexContentSync" class="control-mat-label">Plex Sync</mat-label>
<input type="text" matInput [ngClass]="{'form-error': form.get('plexContentSync').hasError('required')}" id="plexContentSync" name="plexContentSync" formControlName="plexContentSync"> <input type="text" matInput [ngClass]="{'form-error': form.get('plexContentSync').hasError('required')}" id="plexContentSync" name="plexContentSync" formControlName="plexContentSync">
<small *ngIf="form.get('plexContentSync').hasError('required')" class="error-text">The Plex Sync is required</small></mat-form-field> <small *ngIf="form.get('plexContentSync').hasError('required')" class="error-text">The Plex Sync is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('plexContentSync')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('plexContentSync')?.value)">Test</button>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
<mat-form-field appearance="outline" floatLabel=always> <mat-form-field appearance="outline" floatLabel=always>
<mat-label for="plexRecentlyAddedSync" class="control-mat-label">Plex Recently Added Sync</mat-label> <mat-label for="plexRecentlyAddedSync" class="control-mat-label">Plex Recently Added Sync</mat-label>
<input type="text" matInput [ngClass]="{'form-error': form.get('plexRecentlyAddedSync').hasError('required')}" id="plexRecentlyAddedSync" name="plexRecentlyAddedSync" formControlName="plexRecentlyAddedSync"> <input type="text" matInput [ngClass]="{'form-error': form.get('plexRecentlyAddedSync').hasError('required')}" id="plexRecentlyAddedSync" name="plexRecentlyAddedSync" formControlName="plexRecentlyAddedSync">
<small *ngIf="form.get('plexRecentlyAddedSync').hasError('required')" class="error-text">The Plex Sync is required</small></mat-form-field> <small *ngIf="form.get('plexRecentlyAddedSync').hasError('required')" class="error-text">The Plex Sync is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('plexRecentlyAddedSync')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('plexRecentlyAddedSync')?.value)">Test</button>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
<mat-form-field appearance="outline" floatLabel=always> <mat-form-field appearance="outline" floatLabel=always>
<mat-label for="plexWatchlistImport" class="control-mat-label">Plex Watchlist Import</mat-label> <mat-label for="plexWatchlistImport" class="control-mat-label">Plex Watchlist Import</mat-label>
<input type="text" matInput [ngClass]="{'form-error': form.get('plexWatchlistImport').hasError('required')}" id="plexWatchlistImport" name="plexWatchlistImport" formControlName="plexWatchlistImport"> <input type="text" matInput [ngClass]="{'form-error': form.get('plexWatchlistImport').hasError('required')}" id="plexWatchlistImport" name="plexWatchlistImport" formControlName="plexWatchlistImport">
<small *ngIf="form.get('plexWatchlistImport').hasError('required')" class="error-text">The Plex Watchlist Import is required</small></mat-form-field> <small *ngIf="form.get('plexWatchlistImport').hasError('required')" class="error-text">The Plex Watchlist Import is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('plexWatchlistImport')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('plexWatchlistImport')?.value)">Test</button>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
@ -90,15 +90,15 @@
<mat-label for="embyContentSync" class="control-mat-label">Emby Sync</mat-label> <mat-label for="embyContentSync" class="control-mat-label">Emby Sync</mat-label>
<input type="text" matInput [ngClass]="{'form-error': form.get('embyContentSync').hasError('required')}" id="embyContentSync" name="embyContentSync" formControlName="embyContentSync"> <input type="text" matInput [ngClass]="{'form-error': form.get('embyContentSync').hasError('required')}" id="embyContentSync" name="embyContentSync" formControlName="embyContentSync">
<small *ngIf="form.get('embyContentSync').hasError('required')" class="error-text">The Emby Sync is required</small></mat-form-field> <small *ngIf="form.get('embyContentSync').hasError('required')" class="error-text">The Emby Sync is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('embyContentSync')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('embyContentSync')?.value)">Test</button>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
<mat-form-field appearance="outline" floatLabel=always> <mat-form-field appearance="outline" floatLabel=always>
<mat-label for="embyRecentlyAddedSync" class="control-mat-label">Emby Recently Added Sync</mat-label> <mat-label for="embyRecentlyAddedSync" class="control-mat-label">Emby Recently Added Sync</mat-label>
<input type="text" matInput [ngClass]="{'form-error': form.get('embyRecentlyAddedSync').hasError('required')}" id="embyRecentlyAddedSync" name="embyRecentlyAddedSync" formControlName="embyRecentlyAddedSync"> <input type="text" matInput [ngClass]="{'form-error': form.get('embyRecentlyAddedSync').hasError('required')}" id="embyRecentlyAddedSync" name="embyRecentlyAddedSync" formControlName="embyRecentlyAddedSync">
<small *ngIf="form.get('embyRecentlyAddedSync').hasError('required')" class="error-text">The Emby Recently Added Sync is required</small></mat-form-field> <small *ngIf="form.get('embyRecentlyAddedSync').hasError('required')" class="error-text">The Emby Recently Added Sync is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('embyRecentlyAddedSync')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('embyRecentlyAddedSync')?.value)">Test</button>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
@ -106,7 +106,7 @@
<mat-label for="jellyfinContentSync" class="control-label">Jellyfin Sync</mat-label> <mat-label for="jellyfinContentSync" class="control-label">Jellyfin Sync</mat-label>
<input type="text" matInput [ngClass]="{'form-error': form.get('jellyfinContentSync').hasError('required')}" id="jellyfinContentSync" name="jellyfinContentSync" formControlName="jellyfinContentSync"> <input type="text" matInput [ngClass]="{'form-error': form.get('jellyfinContentSync').hasError('required')}" id="jellyfinContentSync" name="jellyfinContentSync" formControlName="jellyfinContentSync">
<small *ngIf="form.get('jellyfinContentSync').hasError('required')" class="error-text">The Jellyfin Sync is required</small></mat-form-field> <small *ngIf="form.get('jellyfinContentSync').hasError('required')" class="error-text">The Jellyfin Sync is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('jellyfinContentSync')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('jellyfinContentSync')?.value)">Test</button>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
@ -114,31 +114,31 @@
<mat-label for="userImporter" class="control-mat-label">User Importer</mat-label> <mat-label for="userImporter" class="control-mat-label">User Importer</mat-label>
<input type="text" matInput [ngClass]="{'form-error': form.get('userImporter').hasError('required')}" id="userImporter" name="userImporter" formControlName="userImporter"> <input type="text" matInput [ngClass]="{'form-error': form.get('userImporter').hasError('required')}" id="userImporter" name="userImporter" formControlName="userImporter">
<small *ngIf="form.get('userImporter').hasError('required')" class="error-text">The User Importer is required</small></mat-form-field> <small *ngIf="form.get('userImporter').hasError('required')" class="error-text">The User Importer is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('userImporter')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('userImporter')?.value)">Test</button>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
<mat-form-field appearance="outline" floatLabel=always> <mat-form-field appearance="outline" floatLabel=always>
<mat-label for="userImporter" class="control-mat-label">Newsletter</mat-label> <mat-label for="userImporter" class="control-mat-label">Newsletter</mat-label>
<input type="text" matInput [ngClass]="{'form-error': form.get('newsletter').hasError('required')}" id="newsletter" name="newsletter" formControlName="newsletter"> <input type="text" matInput [ngClass]="{'form-error': form.get('newsletter').hasError('required')}" id="newsletter" name="newsletter" formControlName="newsletter">
<small *ngIf="form.get('newsletter').hasError('required')" class="error-text">The Newsletter is required</small></mat-form-field> <small *ngIf="form.get('newsletter').hasError('required')" class="error-text">The Newsletter is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('newsletter')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('newsletter')?.value)">Test</button>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
<mat-form-field appearance="outline" floatLabel=always> <mat-form-field appearance="outline" floatLabel=always>
<mat-label for="userImporter" class="control-mat-label">Issue Purge/Delete</mat-label> <mat-label for="userImporter" class="control-mat-label">Issue Purge/Delete</mat-label>
<input type="text" matInput [ngClass]="{'form-error': form.get('issuesPurge').hasError('required')}" id="issuesPurge" name="issuesPurge" formControlName="issuesPurge"> <input type="text" matInput [ngClass]="{'form-error': form.get('issuesPurge').hasError('required')}" id="issuesPurge" name="issuesPurge" formControlName="issuesPurge">
<small *ngIf="form.get('issuesPurge').hasError('required')" class="error-text">The Issues Purge is required</small></mat-form-field> <small *ngIf="form.get('issuesPurge').hasError('required')" class="error-text">The Issues Purge is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('issuesPurge')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('issuesPurge')?.value)">Test</button>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
<mat-form-field appearance="outline" floatLabel=always> <mat-form-field appearance="outline" floatLabel=always>
<mat-label for="userImporter" class="control-mat-label">Media Data Refresh</mat-label> <mat-label for="userImporter" class="control-mat-label">Media Data Refresh</mat-label>
<input type="text" matInput [ngClass]="{'form-error': form.get('mediaDatabaseRefresh').hasError('required')}" id="mediaDatabaseRefresh" name="mediaDatabaseRefresh" formControlName="mediaDatabaseRefresh"> <input type="text" matInput [ngClass]="{'form-error': form.get('mediaDatabaseRefresh').hasError('required')}" id="mediaDatabaseRefresh" name="mediaDatabaseRefresh" formControlName="mediaDatabaseRefresh">
<small *ngIf="form.get('mediaDatabaseRefresh').hasError('required')" class="error-text">The Media Database Refresh is required</small></mat-form-field> <small *ngIf="form.get('mediaDatabaseRefresh').hasError('required')" class="error-text">The Media Database Refresh is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('mediaDatabaseRefresh')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('mediaDatabaseRefresh')?.value)">Test</button>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
@ -146,15 +146,15 @@
<mat-label for="userImporter" class="control-mat-label">Auto Available Request Deletion</mat-label> <mat-label for="userImporter" class="control-mat-label">Auto Available Request Deletion</mat-label>
<input type="text" matInput [ngClass]="{'form-error': form.get('autoDeleteRequests').hasError('required')}" id="autoDeleteRequests" name="autoDeleteRequests" formControlName="autoDeleteRequests"> <input type="text" matInput [ngClass]="{'form-error': form.get('autoDeleteRequests').hasError('required')}" id="autoDeleteRequests" name="autoDeleteRequests" formControlName="autoDeleteRequests">
<small *ngIf="form.get('autoDeleteRequests').hasError('required')" class="error-text">Auto Available Request Deletion is required</small></mat-form-field> <small *ngIf="form.get('autoDeleteRequests').hasError('required')" class="error-text">Auto Available Request Deletion is required</small></mat-form-field>
<button mat-raised-button type="button" class="btn btn-sm btn-primary-outline cronbtn" (click)="testCron(form.get('autoDeleteRequests')?.value)">Test</button> <button mat-raised-button type="button" class="cronbtn" (click)="testCron(form.get('autoDeleteRequests')?.value)">Test</button>
</div> </div>
</div> </div>
<div class="form-group cronBox"> <div class="form-group cronBox">
<div> <div>
<button mat-raised-button type="submit" [disabled]="form.invalid" class="mat-focus-indicator mat-raised-button mat-button-base mat-accent">Submit</button> <button mat-raised-button type="submit" [disabled]="form.invalid" class="mat-focus-indicator mat-raised-button mat-button-base mat-accent">Submit</button>
</div> </div>
</div> </div>
</form> </form>
</fieldset> </fieldset>
</div> </div>

@ -5,34 +5,42 @@
<legend>Mass Email</legend> <legend>Mass Email</legend>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="form-group">
<input type="text" class="form-control form-control-custom " id="subject" name="subject" placeholder="Subject" [(ngModel)]="subject" [ngClass]="{'form-error': missingSubject}">
<small *ngIf="missingSubject" class="error-text">Hey! We need a subject!</small>
</div>
<div class="form-group" > <div class="form-group">
<textarea rows="10" type="text" class="form-control-custom form-control" id="themeContent" name="themeContent" [(ngModel)]="message" placeholder="This supports HTML"></textarea> <mat-form-field>
</div> <mat-label>Subject</mat-label>
<input matInput type="text" id="subject" name="subject" placeholder="Subject" [(ngModel)]="subject" [ngClass]="{'form-error': missingSubject}">
<small *ngIf="missingSubject" class="error-text">Hey! We need a subject!</small>
</mat-form-field>
</div>
<div class="form-group"> <div class="form-group">
<label for="logo" class="control-label">Message Preview</label> <mat-form-field appearance="outline">
<br/> <mat-label>Content</mat-label>
<small>May appear differently on email clients</small> <textarea matInput rows="10" type="text" id="themeContent" name="themeContent" [(ngModel)]="message" placeholder="This supports HTML"></textarea>
<hr/> </mat-form-field>
<div [innerHTML]="message"></div> </div>
<hr/>
</div> <div class="form-group">
<small>This will send out the Mass email BCC'ing all of the selected users rather than sending individual messages</small> <label for="logo" class="control-label">Message Preview</label>
<div class="md-form-field"> <br/>
<mat-slide-toggle [(ngModel)]="bcc">BCC</mat-slide-toggle> <small>May appear differently on email clients</small>
</div> <mat-divider></mat-divider>
<br> <div [innerHTML]="message"></div>
<br> <mat-divider></mat-divider>
<div class="form-group"> </div>
<div>
<button type="submit" id="save" (click)="send()" class="mat-focus-indicator btn-spacing mat-raised-button mat-button-base mat-accent">Send</button> <small>This will send out the Mass email BCC'ing all of the selected users rather than sending individual messages</small>
</div> <div class="md-form-field">
</div> <mat-slide-toggle [(ngModel)]="bcc">BCC</mat-slide-toggle>
</div>
<br>
<br>
<div class="form-group">
<div>
<button type="submit" id="save" (click)="send()" class="mat-focus-indicator btn-spacing mat-raised-button mat-button-base mat-accent">Send</button>
</div>
</div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<!--Users Section--> <!--Users Section-->

@ -7,42 +7,42 @@
<form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)"> <form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)">
<div class="form-group"> <div class="form-group">
<div class="checkbox"> <mat-checkbox id="enable" formControlName="enabled">Enabled</mat-checkbox>
<input type="checkbox" id="enable" formControlName="enabled">
<label for="enable">Enabled</label>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="baseUrl" class="control-label">Base URL</label> <mat-form-field>
<mat-label>Base URL</mat-label>
<input type="text" class="form-control form-control-custom " id="baseUrl" name="baseUrl" [ngClass]="{'form-error': form.get('baseUrl').hasError('required')}" formControlName="baseUrl" pTooltip="Enter the URL of your gotify server."> <input matInput type="text" id="baseUrl" name="baseUrl" [ngClass]="{'form-error': form.get('baseUrl').hasError('required')}" formControlName="baseUrl" pTooltip="Enter the URL of your gotify server.">
<small *ngIf="form.get('baseUrl').hasError('required')" class="error-text">The Base URL is required</small> </mat-form-field>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="applicationToken" class="control-label">Application Token</label> <mat-form-field>
<mat-label>Application Token</mat-label>
<input type="text" class="form-control form-control-custom " id="applicationToken" name="applicationToken" [ngClass]="{'form-error': form.get('applicationToken').hasError('required')}" formControlName="applicationToken" pTooltip="Enter your Application token from Gotify."> <input matInput type="text" id="applicationToken" name="applicationToken" [ngClass]="{'form-error': form.get('applicationToken').hasError('required')}" formControlName="applicationToken" pTooltip="Enter your Application token from Gotify.">
<small *ngIf="form.get('applicationToken').hasError('required')" class="error-text">The Application Token is required</small> </mat-form-field>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="priority" class="control-label">Priority</label> <mat-form-field>
<mat-label>Priority</mat-label>
<mat-select id="priority" name="priority" formControlName="priority" pTooltip="The priority you want your gotify notifications sent as.">
<mat-option value="4">Normal</mat-option>
<mat-option value="8">High</mat-option>
<mat-option value="2">Low</mat-option>
<mat-option value="0">Lowest</mat-option>
</mat-select>
</mat-form-field>
<div> <div>
<select class="form-control form-control-custom " id="priority" name="priority" formControlName="priority" pTooltip="The priority you want your gotify notifications sent as.">
<option value="4">Normal</option>
<option value="8">High</option>
<option value="2">Low</option>
<option value="0">Lowest</option>
</select>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div> <div>
<button [disabled]="form.invalid" mat-raised-button type="button" (click)="test(form)" class="btn btn-primary-outline"> <button [disabled]="form.invalid" mat-raised-button type="button" (click)="test(form)">
Test Test
<div id="spinner"></div> <div id="spinner"></div>
</button> </button>
@ -53,7 +53,7 @@
<div class="form-group"> <div class="form-group">
<div> <div>
<button [disabled]="form.invalid" mat-raised-button type="submit" id="save" class="btn btn-primary-outline">Submit</button> <button [disabled]="form.invalid" mat-raised-button type="submit" id="save">Submit</button>
</div> </div>
</div> </div>
</form> </form>
@ -64,4 +64,4 @@
<notification-templates [templates]="templates" [showSubject]="false"></notification-templates> <notification-templates [templates]="templates" [showSubject]="false"></notification-templates>
</div> </div>
</fieldset> </fieldset>
</div> </div>

@ -7,47 +7,42 @@
<form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)"> <form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)">
<div class="form-group"> <div class="form-group">
<div class="checkbox"> <mat-checkbox id="enable" formControlName="enabled">Enabled</mat-checkbox>
<input type="checkbox" id="enable" formControlName="enabled">
<label for="enable">Enabled</label>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<small class="control-label"> Mattermost > Integrations > Incoming Webhook > Add Incoming Webhook. You will then have a Webhook</small> <small class="control-label"> Mattermost > Integrations > Incoming Webhook > Add Incoming Webhook. You will then have a Webhook</small>
<label for="webhookUrl" class="control-label">Incoming Webhook Url</label> <mat-form-field>
<mat-label>Incoming Webhook Url</mat-label>
<input type="text" class="form-control form-control-custom " id="webhookUrl" name="webhookUrl" formControlName="webhookUrl" [ngClass]="{'form-error': form.get('webhookUrl').hasError('required')}"> <input matInput type="text" id="webhookUrl" name="webhookUrl" formControlName="webhookUrl" [ngClass]="{'form-error': form.get('webhookUrl').hasError('required')}">
<small *ngIf="form.get('webhookUrl').hasError('required')" class="error-text">The Webhook Url is required</small> </mat-form-field>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="channel" class="control-label">Channel Override</label> <mat-form-field>
<div> <mat-label>Channel Override</mat-label>
<input type="text" class="form-control form-control-custom " id="channel" name="channel" formControlName="channel" pTooltip="Optional, you can override the default channel"> <input matInput type="text" id="channel" name="channel" formControlName="channel" pTooltip="Optional, you can override the default channel">
</div> </mat-form-field>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="username" class="control-label">Username Override</label> <mat-form-field>
<div> <mat-label>Username Override</mat-label>
<input type="text" class="form-control form-control-custom " id="username" name="username" formControlName="username" pTooltip="Optional, this will override the username you used for the Webhook"> <input matInput type="text" id="username" name="username" formControlName="username" pTooltip="Optional, this will override the username you used for the Webhook">
</div> </mat-form-field>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="iconUrl" class="control-label">Icon Override</label> <mat-form-field>
<div> <mat-label>Icon Override</mat-label>
<input type="text" class="form-control form-control-custom " id="iconUrl" name="iconUrl" formControlName="iconUrl" pTooltip="Optional, this will override the icon you use for the Webhook"> <input matInput type="text" id="iconUrl" name="iconUrl" formControlName="iconUrl" pTooltip="Optional, this will override the icon you use for the Webhook">
</div> </mat-form-field>
</div> </div>
<div class="form-group"> <div class="form-group">
<div> <div>
<button [disabled]="form.invalid" mat-raised-button type="button" (click)="test(form)" class="btn btn-primary-outline"> <button [disabled]="form.invalid" mat-raised-button type="button" (click)="test(form)">
Test Test
<div id="spinner"></div> <div id="spinner"></div>
</button> </button>
@ -58,7 +53,7 @@
<div class="form-group"> <div class="form-group">
<div> <div>
<button [disabled]="form.invalid" mat-raised-button type="submit" id="save" class="btn btn-primary-outline">Submit</button> <button [disabled]="form.invalid" mat-raised-button type="submit" id="save">Submit</button>
</div> </div>
</div> </div>
</form> </form>
@ -69,4 +64,4 @@
<notification-templates [templates]="templates" [showSubject]="false"></notification-templates> <notification-templates [templates]="templates" [showSubject]="false"></notification-templates>
</div> </div>
</fieldset> </fieldset>
</div> </div>

@ -36,30 +36,29 @@
<div class="row lmobile-actions"> <div class="row lmobile-actions">
<div class="form-group"> <div class="form-group">
<label for="select" class="control-label">Users</label> <mat-form-field>
<div> <mat-label>Users</mat-label>
<select class="form-control form-control-custom" id="select" [(ngModel)]="testUserId" [ngModelOptions]="{standalone: true}"> <mat-select id="select" [(ngModel)]="testUserId" [ngModelOptions]="{standalone: true}">
<option value="">Please select</option> <mat-option *ngFor="let x of userList" [value]="x.userId">{{x.username}}</mat-option>
<option *ngFor="let x of userList" [value]="x.userId">{{x.username}}</option> </mat-select>
</select> </mat-form-field>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div> <div>
<button [disabled]="form.invalid" mat-raised-button type="button" (click)="test(form)" class="btn btn-danger-outline">Send Test Notification</button> <button [disabled]="form.invalid" mat-raised-button type="button" (click)="test(form)" mat-raised-button>Send Test Notification</button>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div> <div>
<button [disabled]="form.invalid" mat-raised-button type="button" (click)="remove(form)" class="btn btn-danger-outline">Remove User</button> <button [disabled]="form.invalid" mat-raised-button type="button" (click)="remove(form)" mat-raised-button>Remove User</button>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div> <div>
<button [disabled]="form.invalid" mat-raised-button type="submit" id="save" class="btn btn-primary-outline">Submit</button> <button [disabled]="form.invalid" mat-raised-button type="submit" id="save" mat-raised-button>Submit</button>
</div> </div>
</div> </div>
</div> </div>
@ -72,4 +71,4 @@
</div> </div>
</div> </div>
</fieldset> </fieldset>
</div> </div>

@ -11,7 +11,7 @@
<mat-slide-toggle type="checkbox" id="enabled" [(ngModel)]="settings.enabled" ng-checked="settings.enabled">Enable</mat-slide-toggle> <mat-slide-toggle type="checkbox" id="enabled" [(ngModel)]="settings.enabled" ng-checked="settings.enabled">Enable</mat-slide-toggle>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="checkbox"> <div class="checkbox">
<mat-slide-toggle type="checkbox" id="disableTv" [(ngModel)]="settings.disableTv" ng-checked="settings.disableTv">Disable TV</mat-slide-toggle> <mat-slide-toggle type="checkbox" id="disableTv" [(ngModel)]="settings.disableTv" ng-checked="settings.disableTv">Disable TV</mat-slide-toggle>
@ -46,11 +46,11 @@
<div class="form-group"> <div class="form-group">
<div> <div>
<button mat-raised-button type="submit" id="save" (click)="onSubmit()" class="btn btn-primary-outline">Submit</button> <button mat-raised-button type="submit" id="save" (click)="onSubmit()">Submit</button>
<button mat-raised-button type="button" (click)="test()" class="btn btn-danger-outline">Test</button> <button mat-raised-button type="button" (click)="test()">Test</button>
<button mat-raised-button type="button" (click)="updateDatabase()" class="btn btn-info-outline" tooltipPosition="top" matTooltip="I recommend running this with a fresh Ombi install, this will set all the current *found* content to have been sent via Newsletter, <button mat-raised-button type="button" (click)="updateDatabase()"tooltipPosition="top" matTooltip="I recommend running this with a fresh Ombi install, this will set all the current *found* content to have been sent via Newsletter,
if you do not do this then everything that Ombi has found in your libraries will go out on the first email!">Update Database</button> if you do not do this then everything that Ombi has found in your libraries will go out on the first email!">Update Database</button>
<button mat-raised-button type="button" (click)="trigger()" class="btn btn-danger-outline">Trigger now</button> <button mat-raised-button type="button" (click)="trigger()">Trigger now</button>
</div> </div>
</div> </div>
</div> </div>
@ -73,7 +73,7 @@
</mat-form-field> </mat-form-field>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<button mat-raised-button class="btn btn-primary-outline" (click)="addEmail()" matTooltip="Don't forget to press the Submit button!">Add</button> <button mat-raised-button (click)="addEmail()" matTooltip="Don't forget to press the Submit button!">Add</button>
</div> </div>
</div> </div>
@ -83,10 +83,10 @@
{{email}} {{email}}
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<button mat-raised-button class="btn btn-sm btn-danger-outline" (click)="deleteEmail(email)">Delete</button> <button mat-raised-button (click)="deleteEmail(email)">Delete</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</fieldset> </fieldset>
</div> </div>

@ -7,32 +7,24 @@
<form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)"> <form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)">
<div class="form-group"> <div class="form-group">
<div class="checkbox"> <mat-checkbox id="enable" formControlName="enabled">Enabled</mat-checkbox>
<input type="checkbox" id="enable" formControlName="enabled">
<label for="enable">Enabled</label>
</div>
</div> </div>
<small>You can find this here: <a href="https://www.pushbullet.com/#settings/account" target="_blank">https://www.pushbullet.com/#settings/account </a></small> <small>You can find this here: <a href="https://www.pushbullet.com/#settings/account" target="_blank">https://www.pushbullet.com/#settings/account </a></small>
<div class="form-group">
<label for="accessToken" class="control-label">Access Token</label>
<input type="text" class="form-control form-control-custom " id="accessToken" name="accessToken" formControlName="accessToken" [ngClass]="{'form-error': form.get('accessToken').hasError('required')}">
<small *ngIf="form.get('accessToken').hasError('required')" class="error-text">The Access Token is required</small>
</div>
<div class="form-group"> <mat-form-field>
<label for="channelTag" class="control-label">Channel Tag</label> <mat-label>Access Token</mat-label>
<div> <input matInput type="text" id="accessToken" name="accessToken" formControlName="accessToken" [ngClass]="{'form-error': form.get('accessToken').hasError('required')}">
<input type="text" class="form-control form-control-custom " id="channelTag" name="channelTag" formControlName="channelTag" pTooltip="Optional, this is if you want to send a message to everyone subscribed to a channel"> </mat-form-field>
</div>
</div>
<mat-form-field>
<mat-label>Channel Tag</mat-label>
<input matInput type="text" id="channelTag" name="channelTag" formControlName="channelTag" pTooltip="Optional, this is if you want to send a message to everyone subscribed to a channel">
</mat-form-field>
<div class="form-group"> <div class="form-group">
<div> <div>
<button [disabled]="form.invalid" mat-raised-button type="button" (click)="test(form)" class="btn btn-primary-outline"> <button [disabled]="form.invalid" mat-raised-button type="button" (click)="test(form)">
Test Test
<div id="spinner"></div> <div id="spinner"></div>
</button> </button>
@ -43,7 +35,7 @@
<div class="form-group"> <div class="form-group">
<div> <div>
<button [disabled]="form.invalid" mat-raised-button type="submit" id="save" class="btn btn-primary-outline">Submit</button> <button [disabled]="form.invalid" mat-raised-button type="submit" id="save">Submit</button>
</div> </div>
</div> </div>
</form> </form>
@ -54,4 +46,4 @@
<notification-templates [templates]="templates" [showSubject]="false"></notification-templates> <notification-templates [templates]="templates" [showSubject]="false"></notification-templates>
</div> </div>
</fieldset> </fieldset>
</div> </div>

@ -7,74 +7,71 @@
<form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)"> <form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)">
<div class="form-group"> <div class="form-group">
<div class="checkbox"> <mat-checkbox id="enable" formControlName="enabled">Enabled</mat-checkbox>
<input type="checkbox" id="enable" formControlName="enabled">
<label for="enable">Enabled</label>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="accessToken" class="control-label">Access Token</label> <mat-form-field>
<mat-label>Access Token</mat-label>
<input type="text" class="form-control form-control-custom " id="accessToken" name="accessToken" [ngClass]="{'form-error': form.get('accessToken').hasError('required')}" formControlName="accessToken" pTooltip="Enter your API Key from Pushover."> <input matInput type="text" id="accessToken" name="accessToken" [ngClass]="{'form-error': form.get('accessToken').hasError('required')}" formControlName="accessToken" pTooltip="Enter your API Key from Pushover.">
<small *ngIf="form.get('accessToken').hasError('required')" class="error-text">The Access Token is required</small> </mat-form-field>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="userToken" class="control-label">User Token</label> <mat-form-field>
<div> <mat-label>User Token</mat-label>
<input type="text" class="form-control form-control-custom " id="userToken" name="userToken" formControlName="userToken" pTooltip="Your user or group key from Pushover."> <input matInput type="text" id="userToken" name="userToken" formControlName="userToken" pTooltip="Your user or group key from Pushover.">
</div> </mat-form-field>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="priority" class="control-label">Priority</label> <mat-form-field>
<div> <mat-label>Priority</mat-label>
<select class="form-control form-control-custom " id="priority" name="priority" formControlName="priority" pTooltip="The priority you want your pushover notifications sent as."> <mat-select id="priority" name="priority" formControlName="priority" pTooltip="The priority you want your pushover notifications sent as.">
<option value="0">Normal</option> <mat-option value="0">Normal</mat-option>
<option value="1">High</option> <mat-option value="1">High</mat-option>
<option value="-1">Low</option> <mat-option value="-1">Low</mat-option>
<option value="-2">Lowest</option> <mat-option value="-2">Lowest</mat-option>
</select> </mat-select>
</div> </mat-form-field>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="sound" class="control-label">Sound</label> <mat-form-field>
<div> <mat-label>Sound</mat-label>
<select class="form-control form-control-custom " id="sound" name="sound" formControlName="sound" pTooltip="The sound you want your pushover notifications sent with."> <mat-select id="sound" name="sound" formControlName="sound" pTooltip="The sound you want your pushover notifications sent with.">
<option value="pushover">Pushover</option> <mat-option value="pushover">Pushover</mat-option>
<option value="bike">Bike</option> <mat-option value="bike">Bike</mat-option>
<option value="bugle">Bugle</option> <mat-option value="bugle">Bugle</mat-option>
<option value="cashregister">Cash Register</option> <mat-option value="cashregister">Cash Register</mat-option>
<option value="classical">Classical</option> <mat-option value="classical">Classical</mat-option>
<option value="cosmic">Cosmic</option> <mat-option value="cosmic">Cosmic</mat-option>
<option value="falling">Falling</option> <mat-option value="falling">Falling</mat-option>
<option value="gamelan">Gamelan</option> <mat-option value="gamelan">Gamelan</mat-option>
<option value="incoming">Incoming</option> <mat-option value="incoming">Incoming</mat-option>
<option value="intermission">Intermission</option> <mat-option value="intermission">Intermission</mat-option>
<option value="magic">Magic</option> <mat-option value="magic">Magic</mat-option>
<option value="mechanical">Mechanical</option> <mat-option value="mechanical">Mechanical</mat-option>
<option value="pianobar">Piano Bar</option> <mat-option value="pianobar">Piano Bar</mat-option>
<option value="siren">Siren</option> <mat-option value="siren">Siren</mat-option>
<option value="spacealarm">Space Alarm</option> <mat-option value="spacealarm">Space Alarm</mat-option>
<option value="tugboat">Tug Boat</option> <mat-option value="tugboat">Tug Boat</mat-option>
<option value="alien">Alien Alarm (long)</option> <mat-option value="alien">Alien Alarm (long)</mat-option>
<option value="climb">Climb (long)</option> <mat-option value="climb">Climb (long)</mat-option>
<option value="persistent">Persistent (long)</option> <mat-option value="persistent">Persistent (long)</mat-option>
<option value="echo">Pushover Echo (long)</option> <mat-option value="echo">Pushover Echo (long)</mat-option>
<option value="updown">Up Down (long)</option> <mat-option value="updown">Up Down (long)</mat-option>
<option value="none">None</option> <mat-option value="none">None</mat-option>
</select> </mat-select>
</div> </mat-form-field>
</div> </div>
<div class="form-group"> <div class="form-group">
<div> <div>
<button [disabled]="form.invalid" mat-raised-button type="button" (click)="test(form)" class="btn btn-primary-outline"> <button [disabled]="form.invalid" mat-raised-button type="button" (click)="test(form)">
Test Test
<div id="spinner"></div> <div id="spinner"></div>
</button> </button>
@ -85,7 +82,7 @@
<div class="form-group"> <div class="form-group">
<div> <div>
<button [disabled]="form.invalid" mat-raised-button type="submit" id="save" class="btn btn-primary-outline">Submit</button> <button [disabled]="form.invalid" mat-raised-button type="submit" id="save">Submit</button>
</div> </div>
</div> </div>
</form> </form>
@ -96,4 +93,4 @@
<notification-templates [templates]="templates" [showSubject]="false"></notification-templates> <notification-templates [templates]="templates" [showSubject]="false"></notification-templates>
</div> </div>
</fieldset> </fieldset>
</div> </div>

@ -7,57 +7,51 @@
<form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)"> <form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)">
<div class="form-group"> <div class="form-group">
<mat-checkbox id="enable" formControlName="enabled">Enabled</mat-checkbox>
<div class="checkbox">
<input type="checkbox" id="enable" formControlName="enabled">
<label for="enable">Enabled</label>
</div>
</div> </div>
<div class="form-group">
<label for="webhookUrl" class="control-label">Webhook Url</label>
<div>
<small class="control-label"> Click <a target="_blank" href="https://my.slack.com/services/new/incoming-webhook/">Here</a> and follow the guide. You will then have a Webhook Url</small>
<input type="text" class="form-control form-control-custom " id="webhookUrl" name="webhookUrl" formControlName="webhookUrl" [ngClass]="{'form-error': form.get('webhookUrl').hasError('required')}">
<small *ngIf="form.get('webhookUrl').hasError('required')" class="error-text">The Webhook Url is required</small>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="username" class="control-label">Username Override</label>
<div> <small class="control-label"> Click <a target="_blank" href="https://my.slack.com/services/new/incoming-webhook/">Here</a> and follow the guide. You will then have a Webhook Url</small>
<input type="text" class="form-control form-control-custom " id="username" name="username" formControlName="username" pTooltip="Optional, this will override the username you used for the Webhook. Default is Ombi"> <mat-form-field>
</div> <mat-label>Webhook Url</mat-label>
</div> <input matInput type="text" id="webhookUrl" name="webhookUrl" formControlName="webhookUrl" [ngClass]="{'form-error': form.get('webhookUrl').hasError('required')}">
<div class="form-group"> </mat-form-field>
<label for="channel" class="control-label">Channel Override</label>
<div>
<input type="text" class="form-control form-control-custom " id="channel" name="channel" formControlName="channel" pTooltip="Optional, this will override the channel you used for the Webhook">
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="iconEmoji" class="control-label">Emoji Icon Override</label> <mat-form-field>
<div> <mat-label>Username Override</mat-label>
<input type="text" class="form-control form-control-custom " id="iconEmoji" name="iconEmoji" formControlName="iconEmoji" pTooltip="Optional, this will override the Icon you used for the Webhook"> <input matInput type="text" id="username" name="username" formControlName="username" pTooltip="Optional, this will override the username you used for the Webhook. Default is Ombi">
</div> </mat-form-field>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="iconUrl" class="control-label">Url Icon Override</label> <mat-form-field>
<div> <mat-label>Channel Override</mat-label>
<input type="text" class="form-control form-control-custom " id="iconUrl" name="iconUrl" formControlName="iconUrl" pTooltip="Optional, this will override the Icon you used for the Webhook"> <input matInput type="text" id="channel" name="channel" formControlName="channel" pTooltip="Optional, this will override the channel you used for the Webhook">
</div> </mat-form-field>
</div> </div>
<div class="form-group">
<mat-form-field>
<mat-label>Emoji Icon Override</mat-label>
<input matInput type="text" id="iconEmoji" name="iconEmoji" formControlName="iconEmoji" pTooltip="Optional, this will override the Icon you used for the Webhook">
</mat-form-field>
</div>
<div class="form-group">
<mat-form-field>
<mat-label>Url Icon Override</mat-label>
<input matInput type="text" id="iconUrl" name="iconUrl" formControlName="iconUrl" pTooltip="Optional, this will override the Icon you used for the Webhook">
</mat-form-field>
</div>
<small>You can find more details about the Slack API <a target="_blank" href="https://api.slack.com/custom-integrations/incoming-webhooks">Here</a></small> <small>You can find more details about the Slack API <a target="_blank" href="https://api.slack.com/custom-integrations/incoming-webhooks">Here</a></small>
<div class="form-group"> <div class="form-group">
<div> <div>
<button [disabled]="form.invalid" mat-raised-button type="button" (click)="test(form)" class="btn btn-primary-outline"> <button [disabled]="form.invalid" mat-raised-button type="button" (click)="test(form)">
Test Test
<div id="spinner"></div> <div id="spinner"></div>
</button> </button>
@ -68,7 +62,7 @@
<div class="form-group"> <div class="form-group">
<div> <div>
<button [disabled]="form.invalid" mat-raised-button type="submit" id="save" class="btn btn-primary-outline">Submit</button> <button [disabled]="form.invalid" mat-raised-button type="submit" id="save">Submit</button>
</div> </div>
</div> </div>
</form> </form>
@ -79,4 +73,4 @@
<notification-templates [templates]="templates" [showSubject]="false"></notification-templates> <notification-templates [templates]="templates" [showSubject]="false"></notification-templates>
</div> </div>
</fieldset> </fieldset>
</div> </div>

@ -7,27 +7,26 @@
<form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)"> <form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)">
<div class="form-group"> <div class="form-group">
<div class="checkbox"> <mat-checkbox id="enable" formControlName="enabled">Enabled</mat-checkbox>
<input type="checkbox" id="enable" formControlName="enabled">
<label for="enable">Enabled</label>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="botApi" class="control-label">Bot API</label> <mat-form-field>
<input type="text" class="form-control form-control-custom " id="botApi" name="botApi" formControlName="botApi" [ngClass]="{'form-error': form.get('botApi').hasError('required')}"> <mat-label>Bot API</mat-label>
<small *ngIf="form.get('botApi').hasError('required')" class="error-text">The Bot API is required</small> <input matInput type="text" id="botApi" name="botApi" formControlName="botApi" [ngClass]="{'form-error': form.get('botApi').hasError('required')}">
</mat-form-field>
<small>You need a bot for Telegram notifications, You can find out how to create a bot <small>You need a bot for Telegram notifications, You can find out how to create a bot
<a href="https://core.telegram.org/bots#6-botfather">here</a>.</small> <a href="https://core.telegram.org/bots#6-botfather">here</a>.</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="chatId" class="control-label">Chat Id</label> <mat-form-field>
<input type="text" class="form-control form-control-custom " id="chatId" name="chatId" formControlName="chatId" [ngClass]="{'form-error': form.get('chatId').hasError('required')}"> <mat-label>Chat Id</mat-label>
<small *ngIf="form.get('chatId').hasError('required')" class="error-text">The Chat Id is required</small> <input matInput type="text" id="chatId" name="chatId" formControlName="chatId" [ngClass]="{'form-error': form.get('chatId').hasError('required')}">
</mat-form-field>
<small>This is the Chat ID from Telegram. You can get the Chat Id from <small>This is the Chat ID from Telegram. You can get the Chat Id from
<a href="https://telegram.me/get_id_bot">here</a>. This also supports Group Chat Id's.</small> <a href="https://telegram.me/get_id_bot">here</a>. This also supports Group Chat Id's.</small>
</div> </div>
@ -43,7 +42,7 @@
<div class="form-group"> <div class="form-group">
<div> <div>
<button [disabled]="form.invalid" mat-raised-button type="button" (click)="test(form)" class="btn btn-primary-outline"> <button [disabled]="form.invalid" mat-raised-button type="button" (click)="test(form)">
Test Test
<div id="spinner"></div> <div id="spinner"></div>
</button> </button>
@ -52,7 +51,7 @@
<div class="form-group"> <div class="form-group">
<div> <div>
<button [disabled]="form.invalid" mat-raised-button type="submit" id="save" class="btn btn-primary-outline">Submit</button> <button [disabled]="form.invalid" mat-raised-button type="submit" id="save">Submit</button>
</div> </div>
</div> </div>
</form> </form>
@ -63,4 +62,4 @@
<notification-templates [templates]="templates" [showSubject]="false"></notification-templates> <notification-templates [templates]="templates" [showSubject]="false"></notification-templates>
</div> </div>
</fieldset> </fieldset>
</div> </div>

@ -7,31 +7,26 @@
<form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)"> <form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)">
<div class="form-group"> <div class="form-group">
<div class="checkbox"> <mat-checkbox id="enable" formControlName="enabled">Enabled</mat-checkbox>
<input type="checkbox" id="enable" formControlName="enabled">
<label for="enable">Enabled</label>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="baseUrl" class="control-label">Base URL</label> <mat-form-field>
<mat-label>Base URL</mat-label>
<input type="text" class="form-control form-control-custom " id="webhookUrl" name="webhookUrl" [ngClass]="{'form-error': form.get('webhookUrl').hasError('required')}" formControlName="webhookUrl" pTooltip="Enter the URL of your webhook server."> <input matInput type="text" id="webhookUrl" name="webhookUrl" [ngClass]="{'form-error': form.get('webhookUrl').hasError('required')}" formControlName="webhookUrl" pTooltip="Enter the URL of your webhook server.">
<small *ngIf="form.get('webhookUrl').hasError('required')" class="error-text">The Webhook URL is required</small> </mat-form-field>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="applicationToken" class="control-label">Application Token <mat-form-field>
<i class="far fa-question-circle" pTooltip="Optional authentication token. Will be sent as 'Access-Token' header."></i> <mat-label>Application Token</mat-label>
</label> <input matInput type="text" id="applicationToken" name="applicationToken" [ngClass]="{'form-error': form.get('applicationToken').hasError('required')}" formControlName="applicationToken" pTooltip="Optional authentication token. Will be sent as 'Access-Token' header.">
</mat-form-field>
<input type="text" class="form-control form-control-custom " id="applicationToken" name="applicationToken" [ngClass]="{'form-error': form.get('applicationToken').hasError('required')}" formControlName="applicationToken" pTooltip="Enter your Application token from Webhook.">
<small *ngIf="form.get('applicationToken').hasError('required')" class="error-text">The Application Token is required</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<div> <div>
<button [disabled]="form.invalid" mat-raised-button type="button" (click)="test(form)" class="btn btn-primary-outline"> <button [disabled]="form.invalid" mat-raised-button type="button" (click)="test(form)">
Test Test
<div id="spinner"></div> <div id="spinner"></div>
</button> </button>
@ -40,10 +35,10 @@
<div class="form-group"> <div class="form-group">
<div> <div>
<button [disabled]="form.invalid" mat-raised-button type="submit" id="save" class="btn btn-primary-outline">Submit</button> <button [disabled]="form.invalid" mat-raised-button type="submit" id="save">Submit</button>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
</fieldset> </fieldset>
</div> </div>

@ -5,47 +5,47 @@
<mat-form-field appearance="outline" floatLabel=auto> <mat-form-field appearance="outline" floatLabel=auto>
<mat-label>Server Name</mat-label> <mat-label>Server Name</mat-label>
<input matInput placeholder="Server Name" name="name" [(ngModel)]="this.data.server.name" value="{{this.data.server.name}}"> <input matInput id="serverName" placeholder="Server Name" name="name" [(ngModel)]="this.data.server.name" value="{{this.data.server.name}}">
</mat-form-field> </mat-form-field>
<div class="row"> <div class="row">
<mat-form-field class="col-md-6 col-12" appearance="outline" floatLabel=auto> <mat-form-field class="col-md-6 col-12" appearance="outline" floatLabel=auto>
<mat-label>Hostname / IP</mat-label> <mat-label>Hostname / IP</mat-label>
<input matInput placeholder="Hostname or IP" name="ip" [(ngModel)]="this.data.server.ip" value="{{this.data.server.ip}}" <input matInput id="ip" placeholder="Hostname or IP" name="ip" [(ngModel)]="this.data.server.ip" value="{{this.data.server.ip}}"
#serverHostnameIpControl="ngModel" required> #serverHostnameIpControl="ngModel" required>
<mat-error *ngIf="serverHostnameIpControl.hasError('required')">Must be specified.</mat-error> <mat-error *ngIf="serverHostnameIpControl.hasError('required')">Must be specified.</mat-error>
</mat-form-field> </mat-form-field>
<mat-form-field class="col-md-4 col-7" appearance="outline" floatLabel=auto> <mat-form-field class="col-md-4 col-7" appearance="outline" floatLabel=auto>
<mat-label>Port</mat-label> <mat-label>Port</mat-label>
<input matInput placeholder="Port" name="port" [(ngModel)]="this.data.server.port" value="{{this.data.server.port}}" <input id="port" matInput placeholder="Port" name="port" [(ngModel)]="this.data.server.port" value="{{this.data.server.port}}"
#serverPortControl="ngModel" required pattern="^[0-9]*$"> #serverPortControl="ngModel" required pattern="^[0-9]*$">
<mat-error *ngIf="serverPortControl.hasError('required')">Must be specified.</mat-error> <mat-error *ngIf="serverPortControl.hasError('required')">Must be specified.</mat-error>
<mat-error *ngIf="serverPortControl.hasError('pattern')">Must be a number.</mat-error> <mat-error *ngIf="serverPortControl.hasError('pattern')">Must be a number.</mat-error>
</mat-form-field> </mat-form-field>
<mat-slide-toggle class="col-md-2 col-5 mt-3" id="ssl" name="ssl" [(ngModel)]="this.data.server.ssl" [checked]="this.data.server.ssl"> <mat-slide-toggle id="ssl" class="col-md-2 col-5 mt-3" id="ssl" name="ssl" [(ngModel)]="this.data.server.ssl" [checked]="this.data.server.ssl">
SSL SSL
</mat-slide-toggle> </mat-slide-toggle>
</div> </div>
<mat-form-field appearance="outline" floatLabel=auto> <mat-form-field appearance="outline" floatLabel=auto>
<mat-label>Plex Authorization Token</mat-label> <mat-label>Plex Authorization Token</mat-label>
<input matInput placeholder="Plex Authorization Token" name="authToken" [(ngModel)]="this.data.server.plexAuthToken" value="{{this.data.server.plexAuthToken}}" <input id="authToken" matInput placeholder="Plex Authorization Token" name="authToken" [(ngModel)]="this.data.server.plexAuthToken" value="{{this.data.server.plexAuthToken}}"
#serverApiKeyControl="ngModel" required> #serverApiKeyControl="ngModel" required>
<mat-error *ngIf="serverApiKeyControl.hasError('required')">Must be specified.</mat-error> <mat-error *ngIf="serverApiKeyControl.hasError('required')">Must be specified.</mat-error>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" floatLabel=auto> <mat-form-field appearance="outline" floatLabel=auto>
<mat-label>Machine Identifier</mat-label> <mat-label>Machine Identifier</mat-label>
<input matInput placeholder="Machine Identifier" name="MachineIdentifier" [(ngModel)]="this.data.server.machineIdentifier" value="{{this.data.server.machineIdentifier}}" <input id="machineId" matInput placeholder="Machine Identifier" name="MachineIdentifier" [(ngModel)]="this.data.server.machineIdentifier" value="{{this.data.server.machineIdentifier}}"
#serverApiKeyControl="ngModel" required> #serverApiKeyControl="ngModel" required>
<mat-error *ngIf="serverApiKeyControl.hasError('required')">Must be specified.</mat-error> <mat-error *ngIf="serverApiKeyControl.hasError('required')">Must be specified.</mat-error>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" floatLabel=auto> <mat-form-field appearance="outline" floatLabel=auto>
<mat-label>Externally Facing Hostname</mat-label> <mat-label>Externally Facing Hostname</mat-label>
<input matInput placeholder="e.g. https://emby.this.data.server.com/" name="serverHostname" name="hostname" <input id="externalHostname" matInput placeholder="e.g. https://emby.this.data.server.com/" name="serverHostname" name="hostname"
[(ngModel)]="this.data.server.serverHostname" value="{{this.data.server.serverHostname}}" > [(ngModel)]="this.data.server.serverHostname" value="{{this.data.server.serverHostname}}" >
<mat-hint> <mat-hint>
This will be the external address that users will navigate to when they press the 'View On Plex' button This will be the external address that users will navigate to when they press the 'View On Plex' button
@ -58,7 +58,7 @@
<mat-form-field appearance="outline" floatLabel=auto> <mat-form-field appearance="outline" floatLabel=auto>
<mat-label>Episode Batch Size</mat-label> <mat-label>Episode Batch Size</mat-label>
<input matInput placeholder="150" name="MachineIdentifier" [(ngModel)]="this.data.server.episodeBatchSize" value="{{this.data.server.episodeBatchSize}}"> <input id="batchSize" matInput placeholder="150" name="MachineIdentifier" [(ngModel)]="this.data.server.episodeBatchSize" value="{{this.data.server.episodeBatchSize}}">
<mat-hint> <mat-hint>
150 by default, you shouldn't need to change this, this sets how many episodes we request from Plex at a single time. 150 by default, you shouldn't need to change this, this sets how many episodes we request from Plex at a single time.
</mat-hint> </mat-hint>
@ -66,7 +66,7 @@
<h2>Libraries</h2> <h2>Libraries</h2>
<div> <div>
<button mat-raised-button (click)="loadLibraries()" <button id="loadLibs" mat-raised-button (click)="loadLibraries()"
class="mat-focus-indicator mat-stroked-button mat-button-base">Load Libraries class="mat-focus-indicator mat-stroked-button mat-button-base">Load Libraries
<i class="fas fa-film"></i> <i class="fas fa-film"></i>
</button> </button>
@ -74,10 +74,10 @@
<div *ngIf="this.data.server.plexSelectedLibraries && this.data.server.plexSelectedLibraries.length > 0"> <div *ngIf="this.data.server.plexSelectedLibraries && this.data.server.plexSelectedLibraries.length > 0">
<label>Please select the libraries for Ombi to monitor. If nothing is selected, Ombi will monitor all <label>Please select the libraries for Ombi to monitor. If nothing is selected, Ombi will monitor all
libraries.</label> libraries.</label>
<div *ngFor="let lib of this.data.server.plexSelectedLibraries"> <div *ngFor="let lib of this.data.server.plexSelectedLibraries; let i = index">
<div class="md-form-field"> <div class="md-form-field">
<div class="checkbox"> <div class="checkbox">
<mat-slide-toggle [(ngModel)]="lib.enabled" [checked]="lib.enabled" <mat-slide-toggle id="lib-{{i}}" [(ngModel)]="lib.enabled" [checked]="lib.enabled"
for="{{lib.title}}">{{lib.title}}</mat-slide-toggle> for="{{lib.title}}">{{lib.title}}</mat-slide-toggle>
</div> </div>
</div> </div>
@ -87,7 +87,7 @@
</mat-dialog-content> </mat-dialog-content>
<mat-dialog-actions align=end> <mat-dialog-actions align=end>
<button style="margin: .5em 0 0 .5em;" align-middle mat-stroked-button color="accent" <button id="testPlexButton" style="margin: .5em 0 0 .5em;" align-middle mat-stroked-button color="accent"
(click)="testPlex()"> (click)="testPlex()">
<span style="display: flex; align-items: baseline; white-space: pre-wrap;"> <span style="display: flex; align-items: baseline; white-space: pre-wrap;">
<i class="fas fa-vial"></i> <i class="fas fa-vial"></i>
@ -95,7 +95,7 @@
</span> </span>
</button> </button>
<button style="margin: .5em 0 0 .5em;" align-middle mat-stroked-button color="warn" <button id="deleteServer" style="margin: .5em 0 0 .5em;" align-middle mat-stroked-button color="warn"
(click)="delete()"> (click)="delete()">
<span style="display: flex; align-items: baseline; white-space: pre-wrap;"> <span style="display: flex; align-items: baseline; white-space: pre-wrap;">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
@ -103,7 +103,7 @@
</span> </span>
</button> </button>
<button style="margin: .5em 0 0 0.5em;" mat-stroked-button color="basic" (click)="cancel()"> <button id="cancel" style="margin: .5em 0 0 0.5em;" mat-stroked-button color="basic" (click)="cancel()">
<span style="display: flex; align-items: baseline; white-space: pre-wrap;"> <span style="display: flex; align-items: baseline; white-space: pre-wrap;">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
<span> Cancel</span> <span> Cancel</span>
@ -111,7 +111,7 @@
</button> </button>
<button style="margin: .5em 0 0 .5em;" mat-stroked-button color="accent" <button id="saveServer" style="margin: .5em 0 0 .5em;" mat-stroked-button color="accent"
(click)="save()"> (click)="save()">
<span style="display: flex; align-items: baseline; white-space: pre-wrap;"> <span style="display: flex; align-items: baseline; white-space: pre-wrap;">
<i style="vertical-align: text-top;" class="fas fa-check"></i> <i style="vertical-align: text-top;" class="fas fa-check"></i>

@ -37,7 +37,7 @@ export class PlexServerDialogComponent {
public testPlex() { public testPlex() {
this.testerService.plexTest(this.data.server).pipe(take(1)) this.testerService.plexTest(this.data.server).pipe(take(1))
.subscribe(x => { .subscribe(x => {
if (x === true) { if (x) {
this.notificationService.success(`Successfully connected to the Plex server ${this.data.server.name}!`); this.notificationService.success(`Successfully connected to the Plex server ${this.data.server.name}!`);
} else { } else {
this.notificationService.error(`We could not connect to the Plex server ${this.data.server.name}!`); this.notificationService.error(`We could not connect to the Plex server ${this.data.server.name}!`);

@ -30,13 +30,13 @@
<h2 style="margin: 1em 0 0 0;">Servers</h2> <h2 style="margin: 1em 0 0 0;">Servers</h2>
<mat-list style="display:flex; flex-flow: wrap;"> <mat-list style="display:flex; flex-flow: wrap;">
<mat-card class="server-card" *ngFor="let server of settings.servers"> <mat-card class="server-card" *ngFor="let server of settings.servers">
<button mat-button (click)="edit(server)"> <button mat-button (click)="edit(server)" id="{{server.name}}-button">
<h3>{{server.name}}</h3> <h3>{{server.name}}</h3>
</button> </button>
</mat-card> </mat-card>
<mat-card class="server-card new-server-card"> <mat-card class="server-card new-server-card">
<button mat-button (click)="newServer()"> <button mat-button (click)="newServer()" id="newServer">
<i class="fas fa-plus fa-xl"></i> <i class="fas fa-plus fa-xl"></i>
<h3>Manually Add Server</h3> <h3>Manually Add Server</h3>
</button> </button>
@ -114,13 +114,13 @@
<div class="md-form-field col-10"> <div class="md-form-field col-10">
<div *ngIf="!loadedServers"> <div *ngIf="!loadedServers">
<mat-form-field appearance="outline" floatLabel=auto> <mat-form-field appearance="outline" floatLabel=auto>
<input disabled matInput placeholder="No Servers Loaded" id="selectServer-noservers"> <input disabled matInput placeholder="No Servers Loaded" id="servers">
</mat-form-field> </mat-form-field>
</div> </div>
<div *ngIf="loadedServers"> <div *ngIf="loadedServers">
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-select placeholder="Servers Loaded! Please Select"> <mat-select placeholder="Servers Loaded! Please Select" id="servers">
<mat-option (click)="selectServer(s)" <mat-option (click)="selectServer(s)"
*ngFor="let s of loadedServers.servers.server" [value]="s.server"> *ngFor="let s of loadedServers.servers.server" [value]="s.server">
{{s.name}}</mat-option> {{s.name}}</mat-option>

@ -137,13 +137,14 @@ export class PlexComponent implements OnInit, OnDestroy {
this.removeServer(server); this.removeServer(server);
} }
if (x.server) { if (x.server) {
console.log(x.server);
var idx = this.settings.servers.findIndex(server => server.id === x.server.id); var idx = this.settings.servers.findIndex(server => server.id === x.server.id);
if (idx >= 0) { if (idx >= 0) {
this.settings.servers[idx] = x.server; this.settings.servers[idx] = x.server;
} else { } else {
this.settings.servers.push(x.server); this.settings.servers.push(x.server);
} }
this.save();
} }
}); });
} }
@ -163,6 +164,7 @@ export class PlexComponent implements OnInit, OnDestroy {
} }
if (x.server) { if (x.server) {
this.settings.servers.push(x.server); this.settings.servers.push(x.server);
this.save();
} }
}); });
} }
@ -173,6 +175,7 @@ export class PlexComponent implements OnInit, OnDestroy {
this.settings.servers.splice(index, 1); this.settings.servers.splice(index, 1);
this.selected.setValue(this.settings.servers.length - 1); this.selected.setValue(this.settings.servers.length - 1);
} }
this.save();
} }
private runCacher(): void { private runCacher(): void {

@ -55,7 +55,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<h3>Default Roles</h3> <h3>Default Roles</h3>
<hr> <mat-divider />
<div *ngFor="let c of claims"> <div *ngFor="let c of claims">
<div class="form-group"> <div class="form-group">
<div> <div>
@ -67,13 +67,13 @@
<h3>Default Request Limits</h3> <h3>Default Request Limits</h3>
<hr> <mat-divider />
<div class="form-group"> <div class="form-group">
<label for="movieRequestLimit" class="control-label">Movie Request Limit</label> <mat-form-field>
<div> <mat-label>Movie Request Limit</mat-label>
<input type="text" [(ngModel)]="settings.movieRequestLimit" class="form-control form-small form-control-custom" id="movieRequestLimit" name="movieRequestLimit" value="{{settings?.movieRequestLimit}}"> <input matInput type="text" [(ngModel)]="settings.movieRequestLimit" id="movieRequestLimit" name="movieRequestLimit" value="{{settings?.movieRequestLimit}}">
</div> </mat-form-field>
</div> </div>
<mat-label>Movie Request Limit Type</mat-label> <mat-label>Movie Request Limit Type</mat-label>
@ -84,23 +84,24 @@
</mat-select> </mat-select>
<div class="form-group"> <div class="form-group">
<label for="episodeRequestLimit" class="control-label">Episode Request Limit</label> <mat-form-field>
<div> <mat-label>Episode Request Limit</mat-label>
<input type="text" [(ngModel)]="settings.episodeRequestLimit" class="form-control form-small form-control-custom" id="episodeRequestLimit" name="episodeRequestLimit" value="{{settings?.episodeRequestLimit}}"> <input matInput type="text" [(ngModel)]="settings.episodeRequestLimit" id="episodeRequestLimit" name="episodeRequestLimit" value="{{settings?.episodeRequestLimit}}">
</div> </mat-form-field>
</div> </div>
<mat-label>Episode Request Limit Type</mat-label> <mat-label>Episode Request Limit Type</mat-label>
<mat-select id="episodeRequestLimitType" [(value)]="settings.episodeRequestLimitType"> <mat-select id="episodeRequestLimitType" [(value)]="settings.episodeRequestLimitType">
<mat-option *ngFor="let value of requestLimitTypes" [value]="value"> <mat-option *ngFor="let value of requestLimitTypes" [value]="value">
{{RequestLimitType[value]}} {{RequestLimitType[value]}}
</mat-option> </mat-option>
</mat-select> </mat-select>
<div class="form-group"> <div class="form-group">
<label for="episodeRequestLimit" class="control-label">Music Request Limit</label> <mat-form-field>
<div> <mat-label>Music Request Limit</mat-label>
<input type="text" [(ngModel)]="settings.musicRequestLimit" class="form-control form-small form-control-custom" id="musicRequestLimit" name="musicRequestLimit" value="{{settings?.musicRequestLimit}}"> <input matInput type="text" [(ngModel)]="settings.musicRequestLimit" id="musicRequestLimit" name="musicRequestLimit" value="{{settings?.musicRequestLimit}}">
</div> </mat-form-field>
</div> </div>
<mat-label>Music Request Limit Type</mat-label> <mat-label>Music Request Limit Type</mat-label>
<mat-select id="musicRequestLimitType" [(value)]="settings.musicRequestLimitType"> <mat-select id="musicRequestLimitType" [(value)]="settings.musicRequestLimitType">

@ -23,6 +23,7 @@ import {MatMenuModule} from '@angular/material/menu';
import { MatNativeDateModule } from '@angular/material/core'; import { MatNativeDateModule } from '@angular/material/core';
import { MatPaginatorModule } from '@angular/material/paginator'; import { MatPaginatorModule } from '@angular/material/paginator';
import { MatProgressSpinnerModule } from "@angular/material/progress-spinner"; import { MatProgressSpinnerModule } from "@angular/material/progress-spinner";
import { MatProgressBarModule } from "@angular/material/progress-bar";
import {MatRadioModule} from '@angular/material/radio'; import {MatRadioModule} from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSidenavModule } from '@angular/material/sidenav'; import { MatSidenavModule } from '@angular/material/sidenav';
@ -66,6 +67,7 @@ import { WatchProvidersSelectComponent } from "./components/watch-providers-sele
MomentModule, MomentModule,
MatCardModule, MatCardModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
MatProgressBarModule,
MatAutocompleteModule, MatAutocompleteModule,
MatInputModule, MatInputModule,
MatTabsModule, MatTabsModule,
@ -99,6 +101,7 @@ import { WatchProvidersSelectComponent } from "./components/watch-providers-sele
TranslateModule, TranslateModule,
SidebarModule, SidebarModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
MatProgressBarModule,
IssuesReportComponent, IssuesReportComponent,
EpisodeRequestComponent, EpisodeRequestComponent,
AdminRequestDialogComponent, AdminRequestDialogComponent,

@ -49,7 +49,6 @@ export class UserPreferenceComponent implements OnInit {
if (user.name) { if (user.name) {
this.username = user.name; this.username = user.name;
} }
this.selectedLang = this.translate.currentLang; this.selectedLang = this.translate.currentLang;
const accessToken = await this.identityService.getAccessToken().toPromise(); const accessToken = await this.identityService.getAccessToken().toPromise();
@ -86,8 +85,10 @@ export class UserPreferenceComponent implements OnInit {
} }
public languageSelected() { public languageSelected() {
this.identityService.updateLanguage(this.selectedLang).subscribe(x => this.notification.success(this.translate.instant("UserPreferences.Updated"))); this.identityService.updateLanguage(this.selectedLang).subscribe(_ => {
this.translate.use(this.selectedLang); this.translate.use(this.selectedLang).subscribe();
this.notification.success(this.translate.instant("UserPreferences.Updated"))
});
} }
public countrySelected() { public countrySelected() {

@ -178,11 +178,11 @@
<div class="col-md-6 col-sm-12"> <div class="col-md-6 col-sm-12">
<button *ngIf="!edit" type="button" mat-raised-button color="accent" data-test="createuserbtn" (click)="create()">Create</button> <button *ngIf="!edit" type="button" mat-raised-button color="accent" data-test="createuserbtn" (click)="create()">Create</button>
<div *ngIf="edit"> <div *ngIf="edit">
<button type="button" data-test="updatebtn" mat-raised-button color="accent" class="btn btn-primary-outline" (click)="update()">Update</button> <button type="button" data-test="updatebtn" mat-raised-button color="accent" (click)="update()">Update</button>
<button type="button" data-test="deletebtn" mat-raised-button color="warn" class="btn btn-danger-outline" (click)="delete()">Delete</button> <button type="button" data-test="deletebtn" mat-raised-button color="warn" (click)="delete()">Delete</button>
<button type="button" style="float:right;" mat-raised-button color="primary" class="btn btn-info-outline" (click)="resetPassword()" matTooltip="You need your SMTP settings setup">Send <button type="button" style="float:right;" mat-raised-button color="primary" (click)="resetPassword()" matTooltip="You need your SMTP settings setup">Send
Reset Password Link</button> Reset Password Link</button>
<button *ngIf="appUrl" type="button" mat-raised-button color="accent" class="btn btn-info-outline" (click)="appLink()" matTooltip="Send this link to the user and they can then open the app and directly login">Copy users App Link</button> <button *ngIf="appUrl" type="button" mat-raised-button color="accent" (click)="appLink()" matTooltip="Send this link to the user and they can then open the app and directly login">Copy users App Link</button>
</div> </div>
@ -196,4 +196,4 @@
</div> </div>

@ -60,7 +60,7 @@ export class UserManagementUserComponent implements OnInit {
this.identityService.getUserById(this.userId).subscribe(x => { this.identityService.getUserById(this.userId).subscribe(x => {
this.user = x; this.user = x;
if (!is4KEnabled) { if (!is4KEnabled) {
this.user.claims = this.user.claims.filter(x => x.value !== "Request4KMovie"); this.user.claims = this.user.claims.filter(x => x.value !== "Request4KMovie" && x.value !== "AutoApprove4KMovie");
} }
}); });
this.requestLimitTypes = [RequestLimitType.Day, RequestLimitType.Week, RequestLimitType.Month]; this.requestLimitTypes = [RequestLimitType.Day, RequestLimitType.Week, RequestLimitType.Month];
@ -68,7 +68,7 @@ export class UserManagementUserComponent implements OnInit {
this.identityService.getAllAvailableClaims().subscribe(x => { this.identityService.getAllAvailableClaims().subscribe(x => {
this.availableClaims = x; this.availableClaims = x;
if (!is4KEnabled) { if (!is4KEnabled) {
this.availableClaims = this.availableClaims.filter(y => y.value !== "Request4KMovie"); this.availableClaims = this.availableClaims.filter(y => y.value !== "Request4KMovie" && y.value !== "AutoApprove4KMovie");
} }
}); });
if(this.edit) { if(this.edit) {

@ -1,6 +1,5 @@
/*************************************************************************************************** /***************************************************************************************************
* Load `$localize` onto the global scope - used if i18n tags appear in Angular templates. * Load `$localize` onto the global scope - used if i18n tags appear in Angular templates.
*/ */
import '@angular/localize/init';
import "core-js/es7/reflect"; import "core-js/es7/reflect";
import "zone.js/dist/zone"; import "zone.js/dist/zone";

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save