diff --git a/.devcontainer/Lidarr.code-workspace b/.devcontainer/Lidarr.code-workspace new file mode 100644 index 000000000..a46158e44 --- /dev/null +++ b/.devcontainer/Lidarr.code-workspace @@ -0,0 +1,13 @@ +// This file is used to open the backend and frontend in the same workspace, which is necessary as +// the frontend has vscode settings that are distinct from the backend +{ + "folders": [ + { + "path": ".." + }, + { + "path": "../frontend" + } + ], + "settings": {} +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..d0fa03d5f --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet +{ + "name": "Lidarr", + "image": "mcr.microsoft.com/devcontainers/dotnet:1-6.0", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "nodeGypDependencies": true, + "version": "20", + "nvmVersion": "latest" + } + }, + "forwardPorts": [8686], + "customizations": { + "vscode": { + "extensions": ["esbenp.prettier-vscode"] + } + } +} diff --git a/.editorconfig b/.editorconfig index dad58944a..57d971bc4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,14 +2,275 @@ # editorconfig.org root = true -[*.{cs,html,js,hbs}] +# NOTE: Requires **VS2019 16.3** or later + +# Stylecop.ruleset +# Description: Rules for Lidarr + +# Code files +[*.cs] charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true indent_style = space indent_size = 4 -[*.less] +# Sort using and Import directives with System.* appearing first +dotnet_sort_system_directives_first = true + +# Avoid "this." and "Me." if not necessary +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left + +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion +dotnet_naming_style.instance_field_style.capitalization = camel_case +dotnet_naming_style.instance_field_style.required_prefix = _ + +# Prefer "var" everywhere +csharp_style_var_for_built_in_types = true +csharp_style_var_when_type_is_apparent = true +csharp_style_var_elsewhere = true +# Prefer "out" variables to be declared inline +csharp_style_inlined_variable_declaration = true + +# Using directive is unnecessary. +dotnet_diagnostic.IDE0005.severity = error +# Use var instead of explicit type +dotnet_diagnostic.IDE0007.severity = error +# Inline variable declaration +dotnet_diagnostic.IDE0018.severity = error + +# Stylecop Rules +dotnet_diagnostic.SA0001.severity = none +dotnet_diagnostic.SA1025.severity = none +dotnet_diagnostic.SA1101.severity = none +dotnet_diagnostic.SA1116.severity = none +dotnet_diagnostic.SA1118.severity = none +dotnet_diagnostic.SA1122.severity = none +dotnet_diagnostic.SA1201.severity = suggestion +dotnet_diagnostic.SA1202.severity = suggestion +dotnet_diagnostic.SA1204.severity = suggestion +dotnet_diagnostic.SA1300.severity = none +dotnet_diagnostic.SA1303.severity = none +dotnet_diagnostic.SA1304.severity = none +dotnet_diagnostic.SA1306.severity = none +dotnet_diagnostic.SA1309.severity = none +dotnet_diagnostic.SA1310.severity = none +dotnet_diagnostic.SA1401.severity = none +dotnet_diagnostic.SA1402.severity = none +dotnet_diagnostic.SA1404.severity = suggestion +dotnet_diagnostic.SA1405.severity = suggestion +dotnet_diagnostic.SA1406.severity = suggestion +dotnet_diagnostic.SA1410.severity = suggestion +dotnet_diagnostic.SA1411.severity = suggestion +dotnet_diagnostic.SA1413.severity = none +dotnet_diagnostic.SA1512.severity = none +dotnet_diagnostic.SA1516.severity = none +dotnet_diagnostic.SA1600.severity = none +dotnet_diagnostic.SA1601.severity = none +dotnet_diagnostic.SA1602.severity = none +dotnet_diagnostic.SA1604.severity = none +dotnet_diagnostic.SA1605.severity = none +dotnet_diagnostic.SA1606.severity = none +dotnet_diagnostic.SA1607.severity = none +dotnet_diagnostic.SA1608.severity = none +dotnet_diagnostic.SA1610.severity = none +dotnet_diagnostic.SA1611.severity = none +dotnet_diagnostic.SA1612.severity = none +dotnet_diagnostic.SA1613.severity = none +dotnet_diagnostic.SA1614.severity = none +dotnet_diagnostic.SA1615.severity = none +dotnet_diagnostic.SA1616.severity = none +dotnet_diagnostic.SA1617.severity = none +dotnet_diagnostic.SA1618.severity = none +dotnet_diagnostic.SA1619.severity = none +dotnet_diagnostic.SA1620.severity = none +dotnet_diagnostic.SA1621.severity = none +dotnet_diagnostic.SA1622.severity = none +dotnet_diagnostic.SA1623.severity = none +dotnet_diagnostic.SA1624.severity = none +dotnet_diagnostic.SA1625.severity = none +dotnet_diagnostic.SA1626.severity = none +dotnet_diagnostic.SA1627.severity = none +dotnet_diagnostic.SA1629.severity = none +dotnet_diagnostic.SA1633.severity = none +dotnet_diagnostic.SA1634.severity = none +dotnet_diagnostic.SA1635.severity = none +dotnet_diagnostic.SA1636.severity = none +dotnet_diagnostic.SA1637.severity = none +dotnet_diagnostic.SA1638.severity = none +dotnet_diagnostic.SA1640.severity = none +dotnet_diagnostic.SA1641.severity = none +dotnet_diagnostic.SA1642.severity = none +dotnet_diagnostic.SA1643.severity = none +dotnet_diagnostic.SA1648.severity = none +dotnet_diagnostic.SA1649.severity = none +dotnet_diagnostic.SA1651.severity = none +dotnet_diagnostic.SX1309.severity = warning + +# Microsoft Analyzers that fail and need to be sorted thru +dotnet_diagnostic.ASP0000.severity = suggestion +dotnet_diagnostic.CA1000.severity = suggestion +dotnet_diagnostic.CA1001.severity = suggestion +dotnet_diagnostic.CA1002.severity = suggestion +dotnet_diagnostic.CA1003.severity = suggestion +dotnet_diagnostic.CA1008.severity = suggestion +dotnet_diagnostic.CA1010.severity = suggestion +dotnet_diagnostic.CA1012.severity = suggestion +dotnet_diagnostic.CA1014.severity = suggestion +dotnet_diagnostic.CA1016.severity = suggestion +dotnet_diagnostic.CA1017.severity = suggestion +dotnet_diagnostic.CA1018.severity = suggestion +dotnet_diagnostic.CA1019.severity = suggestion +dotnet_diagnostic.CA1021.severity = suggestion +dotnet_diagnostic.CA1024.severity = suggestion +dotnet_diagnostic.CA1027.severity = suggestion +dotnet_diagnostic.CA1028.severity = suggestion +dotnet_diagnostic.CA1030.severity = suggestion +dotnet_diagnostic.CA1031.severity = suggestion +dotnet_diagnostic.CA1032.severity = suggestion +dotnet_diagnostic.CA1033.severity = suggestion +dotnet_diagnostic.CA1034.severity = suggestion +dotnet_diagnostic.CA1036.severity = suggestion +dotnet_diagnostic.CA1040.severity = suggestion +dotnet_diagnostic.CA1041.severity = suggestion +dotnet_diagnostic.CA1043.severity = suggestion +dotnet_diagnostic.CA1044.severity = suggestion +dotnet_diagnostic.CA1050.severity = suggestion +dotnet_diagnostic.CA1051.severity = suggestion +dotnet_diagnostic.CA1052.severity = suggestion +dotnet_diagnostic.CA1054.severity = suggestion +dotnet_diagnostic.CA1055.severity = suggestion +dotnet_diagnostic.CA1056.severity = suggestion +dotnet_diagnostic.CA1058.severity = suggestion +dotnet_diagnostic.CA1060.severity = suggestion +dotnet_diagnostic.CA1061.severity = suggestion +dotnet_diagnostic.CA1062.severity = suggestion +dotnet_diagnostic.CA1063.severity = suggestion +dotnet_diagnostic.CA1064.severity = suggestion +dotnet_diagnostic.CA1065.severity = suggestion +dotnet_diagnostic.CA1066.severity = suggestion +dotnet_diagnostic.CA1067.severity = suggestion +dotnet_diagnostic.CA1068.severity = suggestion +dotnet_diagnostic.CA1069.severity = suggestion +dotnet_diagnostic.CA1200.severity = suggestion +dotnet_diagnostic.CA1303.severity = suggestion +dotnet_diagnostic.CA1304.severity = suggestion +dotnet_diagnostic.CA1305.severity = suggestion +dotnet_diagnostic.CA1307.severity = suggestion +dotnet_diagnostic.CA1308.severity = suggestion +dotnet_diagnostic.CA1309.severity = suggestion +dotnet_diagnostic.CA1310.severity = suggestion +dotnet_diagnostic.CA1401.severity = suggestion +dotnet_diagnostic.CA1416.severity = suggestion +dotnet_diagnostic.CA1419.severity = suggestion +dotnet_diagnostic.CA1507.severity = suggestion +dotnet_diagnostic.CA1508.severity = suggestion +dotnet_diagnostic.CA1707.severity = suggestion +dotnet_diagnostic.CA1708.severity = suggestion +dotnet_diagnostic.CA1710.severity = suggestion +dotnet_diagnostic.CA1711.severity = suggestion +dotnet_diagnostic.CA1712.severity = suggestion +dotnet_diagnostic.CA1714.severity = suggestion +dotnet_diagnostic.CA1715.severity = suggestion +dotnet_diagnostic.CA1716.severity = suggestion +dotnet_diagnostic.CA1717.severity = suggestion +dotnet_diagnostic.CA1720.severity = suggestion +dotnet_diagnostic.CA1721.severity = suggestion +dotnet_diagnostic.CA1724.severity = suggestion +dotnet_diagnostic.CA1725.severity = suggestion +dotnet_diagnostic.CA1806.severity = suggestion +dotnet_diagnostic.CA1810.severity = suggestion +dotnet_diagnostic.CA1812.severity = suggestion +dotnet_diagnostic.CA1813.severity = suggestion +dotnet_diagnostic.CA1814.severity = suggestion +dotnet_diagnostic.CA1815.severity = suggestion +dotnet_diagnostic.CA1816.severity = suggestion +dotnet_diagnostic.CA1819.severity = suggestion +dotnet_diagnostic.CA1822.severity = suggestion +dotnet_diagnostic.CA1823.severity = suggestion +dotnet_diagnostic.CA1824.severity = suggestion +dotnet_diagnostic.CA1848.severity = suggestion +dotnet_diagnostic.CA2000.severity = suggestion +dotnet_diagnostic.CA2002.severity = suggestion +dotnet_diagnostic.CA2007.severity = suggestion +dotnet_diagnostic.CA2008.severity = suggestion +dotnet_diagnostic.CA2012.severity = suggestion +dotnet_diagnostic.CA2013.severity = suggestion +dotnet_diagnostic.CA2100.severity = suggestion +dotnet_diagnostic.CA2101.severity = suggestion +dotnet_diagnostic.CA2119.severity = suggestion +dotnet_diagnostic.CA2153.severity = suggestion +dotnet_diagnostic.CA2200.severity = suggestion +dotnet_diagnostic.CA2201.severity = suggestion +dotnet_diagnostic.CA2207.severity = suggestion +dotnet_diagnostic.CA2208.severity = suggestion +dotnet_diagnostic.CA2211.severity = suggestion +dotnet_diagnostic.CA2213.severity = suggestion +dotnet_diagnostic.CA2214.severity = suggestion +dotnet_diagnostic.CA2215.severity = suggestion +dotnet_diagnostic.CA2216.severity = suggestion +dotnet_diagnostic.CA2219.severity = suggestion +dotnet_diagnostic.CA2225.severity = suggestion +dotnet_diagnostic.CA2226.severity = suggestion +dotnet_diagnostic.CA2227.severity = suggestion +dotnet_diagnostic.CA2229.severity = suggestion +dotnet_diagnostic.CA2231.severity = suggestion +dotnet_diagnostic.CA2234.severity = suggestion +dotnet_diagnostic.CA2235.severity = suggestion +dotnet_diagnostic.CA2237.severity = suggestion +dotnet_diagnostic.CA2241.severity = suggestion +dotnet_diagnostic.CA2242.severity = suggestion +dotnet_diagnostic.CA2243.severity = suggestion +dotnet_diagnostic.CA2244.severity = suggestion +dotnet_diagnostic.CA2245.severity = suggestion +dotnet_diagnostic.CA2246.severity = suggestion +dotnet_diagnostic.CA2249.severity = suggestion +dotnet_diagnostic.CA2251.severity = suggestion +dotnet_diagnostic.CA2254.severity = suggestion +dotnet_diagnostic.CA3061.severity = suggestion +dotnet_diagnostic.CA3075.severity = suggestion +dotnet_diagnostic.CA3076.severity = suggestion +dotnet_diagnostic.CA3077.severity = suggestion +dotnet_diagnostic.CA3147.severity = suggestion +dotnet_diagnostic.CA5350.severity = suggestion +dotnet_diagnostic.CA5351.severity = suggestion +dotnet_diagnostic.CA5359.severity = suggestion +dotnet_diagnostic.CA5360.severity = suggestion +dotnet_diagnostic.CA5363.severity = suggestion +dotnet_diagnostic.CA5364.severity = suggestion +dotnet_diagnostic.CA5365.severity = suggestion +dotnet_diagnostic.CA5366.severity = suggestion +dotnet_diagnostic.CA5368.severity = suggestion +dotnet_diagnostic.CA5369.severity = suggestion +dotnet_diagnostic.CA5370.severity = suggestion +dotnet_diagnostic.CA5371.severity = suggestion +dotnet_diagnostic.CA5372.severity = suggestion +dotnet_diagnostic.CA5373.severity = suggestion +dotnet_diagnostic.CA5374.severity = suggestion +dotnet_diagnostic.CA5379.severity = suggestion +dotnet_diagnostic.CA5384.severity = suggestion +dotnet_diagnostic.CA5385.severity = suggestion +dotnet_diagnostic.CA5392.severity = suggestion +dotnet_diagnostic.CA5394.severity = suggestion +dotnet_diagnostic.CA5397.severity = suggestion + +dotnet_diagnostic.SYSLIB0006.severity = none + +[*.{js,html,hbs,less,css,ts,tsx}] charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true diff --git a/.esprintrc b/.esprintrc deleted file mode 100644 index 9330e00d1..000000000 --- a/.esprintrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "paths": [ - "frontend/src/**/*.js" - ], - "ignored": [ - "**/node_modules/**/*" - ], - "port": 5004 -} diff --git a/.gitattributes b/.gitattributes index 1b274cb93..d98f8fb96 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,22 +1,10 @@ # Auto detect text files and perform LF normalization -*text eol=lf +* text=auto + +# Explicitly set bash scripts to have unix endings +*.sh text eol=lf +distribution/osx/Lidarr text eol=lf # Custom for Visual Studio *.cs diff=csharp -*.sln merge=union -*.csproj merge=union -*.vbproj merge=union -*.fsproj merge=union -*.dbproj merge=union - -# Standard to msysgit -*.doc diff=astextplain -*.DOC diff=astextplain -*.docx diff=astextplain -*.DOCX diff=astextplain -*.dot diff=astextplain -*.DOT diff=astextplain -*.pdf diff=astextplain -*.PDF diff=astextplain -*.rtf diff=astextplain -*.RTF diff=astextplain \ No newline at end of file +*.sln merge=union \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..49c3e2d71 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,9 @@ +# These are supported funding model platforms + +github: lidarr # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: lidarr +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +custom: # Replace with a single custom sponsorship URL diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 87d054fc8..000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,41 +0,0 @@ - - - -## Support / Questions - -Please use https://discord.gg/8Y7rDc9 for support. Support requests or questions will be redirected to discord and the issue will be closed. - - - -## Bug Report - -### System Information/Logs - -**Lidarr Version:** - -**Operating System:** - -**.net Framework (Windows) or mono (macOS/Linux) Version:** - -**Link to Log Files (debug or trace):** - -**Browser (for UI bugs):** - -### Additional Information - - - -## Feature Request - -### Description of request and what problem are you looking to solve? - -### Other Information diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..491815370 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,83 @@ +name: Bug Report +description: 'Report a new bug, if you are not 100% certain this is a bug please go to our Discord first' +labels: ['Type: Bug', 'Status: Needs Triage'] +body: +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an open or closed issue already exists for the bug you encountered. If a bug exists and is closed note that it may only be fixed in an unstable branch. + options: + - label: I have searched the existing open and closed issues + required: true +- type: textarea + attributes: + label: Current Behavior + description: A concise description of what you're experiencing. + validations: + required: true +- type: textarea + attributes: + label: Expected Behavior + description: A concise description of what you expected to happen. + validations: + required: true +- type: textarea + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. In this environment... + 2. With this config... + 3. Run '...' + 4. See error... + validations: + required: false +- type: textarea + attributes: + label: Environment + description: | + examples: + - **OS**: Ubuntu 20.04 + - **Lidarr**: Lidarr 0.8.1.2135 + - **Docker Install**: Yes + - **Using Reverse Proxy**: No + - **Browser**: Firefox 90 (If UI related) + - **Database**: Sqlite 3.36.0 + value: | + - OS: + - Lidarr: + - Docker Install: + - Using Reverse Proxy: + - Browser: + - Database: + render: markdown + validations: + required: true +- type: dropdown + attributes: + label: What branch are you running? + options: + - Master + - Develop + - Nightly + - Plugins (experimental) + - Other (This issue will be closed) + validations: + required: true +- type: textarea + attributes: + label: Trace Logs? + description: | + Trace Logs (https://wiki.servarr.com/lidarr/troubleshooting#logging-and-log-files) + ***Generally speaking, all bug reports must have trace logs provided.*** + Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. + Additionally, any additional info? Screenshots? References? Anything that will give us more context about the issue you are encountering! + validations: + required: true +- type: checkboxes + attributes: + label: Trace Logs have been provided as applicable. Reports may be closed if the required logs are not provided. + description: Trace logs are generally required for all bug reports and contain `trace`. Info logs are invalid for bug reports and do not contain `debug` nor `trace` + options: + - label: I have read and followed the steps in the wiki link above and provided the required trace logs - the logs contain `trace` - that are relevant and show this issue. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..880a682f4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Support via Discord + url: https://lidarr.audio/discord + about: Chat with users and devs on support and setup related topics. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..ef142b0ad --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,38 @@ +name: Feature Request +description: 'Suggest an idea for Lidarr' +labels: ['Type: Feature Request', 'Status: Needs Triage'] +body: +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an open or closed issue already exists for the feature you are requesting. If a request exists and is closed note that it may only be fixed in an unstable branch. + options: + - label: I have searched the existing open and closed issues + required: true +- type: textarea + attributes: + label: Is your feature request related to a problem? Please describe + description: A clear and concise description of what the problem is. + validations: + required: true +- type: textarea + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + validations: + required: true +- type: textarea + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered. + validations: + required: true +- type: textarea + attributes: + label: Anything else? + description: | + Links? References? Mockups? Anything that will give us more context about the feature you are encountering! + + Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e0d682009..2fcae05cc 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,14 +1,16 @@ #### Database Migration -YES | NO +YES - XXXX | NO #### Description A few sentences describing the overall goals of the pull request's commits. +#### Screenshot (if UI related) + #### Todos - [ ] Tests -- [ ] Documentation - +- [ ] Translation Keys (./src/NzbDrone.Core/Localization/Core/en.json) +- [ ] [Wiki Updates](https://wiki.servarr.com) #### Issues Fixed or Closed by this PR -* +* Fixes #XXXX \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..f33a02cd1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly diff --git a/.github/label-actions.yml b/.github/label-actions.yml new file mode 100644 index 000000000..3979401b1 --- /dev/null +++ b/.github/label-actions.yml @@ -0,0 +1,16 @@ +# Configuration for Label Actions - https://github.com/dessant/label-actions + +'Type: Support': + comment: > + :wave: @{issue-author}, we use the issue tracker exclusively + for bug reports and feature requests. However, this issue appears + to be a support request. Please hop over onto our [Discord](https://lidarr.audio/discord). + close: true + close-reason: 'not planned' + +'Status: Logs Needed': + comment: > + :wave: @{issue-author}, In order to help you further we'll need to see logs. + You'll need to enable trace logging and replicate the problem that you encountered. + Guidance on how to enable trace logging can be found in + our [troubleshooting guide](https://wiki.servarr.com/lidarr/troubleshooting#logging-and-log-files). diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..4203e4418 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,28 @@ +'Area: API': + - src/Lidarr.Api.V1/**/* + +'Area: Db-migration': + - src/NzbDrone.Core/Datastore/Migration/* + +'Area: Download Clients': + - src/NzbDrone.Core/Download/Clients/**/* + +'Area: Import Lists': + - src/NzbDrone.Core/ImportLists/**/* + +'Area: Indexer': + - src/NzbDrone.Core/Indexers/**/* + +'Area: Notifications': + - src/NzbDrone.Core/Notifications/**/* + +'Area: Organizer': + - src/NzbDrone.Core/Organizer/**/* + +'Area: Parser': + - src/NzbDrone.Core/Parser/**/* + +'Area: UI': + - frontend/**/* + - package.json + - yarn.lock diff --git a/.github/workflows/label-actions.yml b/.github/workflows/label-actions.yml new file mode 100644 index 000000000..a6246a6b3 --- /dev/null +++ b/.github/workflows/label-actions.yml @@ -0,0 +1,17 @@ +name: 'Label Actions' + +on: + issues: + types: [labeled, unlabeled] + +permissions: + contents: read + issues: write + +jobs: + action: + runs-on: ubuntu-latest + steps: + - uses: dessant/label-actions@v4 + with: + process-only: 'issues' diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 000000000..857cfb4a7 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,12 @@ +name: "Pull Request Labeler" +on: + - pull_request_target + +jobs: + triage: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v4 diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml new file mode 100644 index 000000000..1d50cb1f1 --- /dev/null +++ b/.github/workflows/lock.yml @@ -0,0 +1,21 @@ +name: 'Lock threads' + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' + +jobs: + lock: + runs-on: ubuntu-latest + steps: + - uses: dessant/lock-threads@v5 + with: + github-token: ${{ github.token }} + issue-inactive-days: '90' + exclude-issue-created-before: '' + exclude-any-issue-labels: '' + add-issue-labels: '' + issue-comment: '' + issue-lock-reason: 'resolved' + process-only: '' diff --git a/.gitignore b/.gitignore index 32f3b4421..a5d6bb7c8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ src/**/[Oo]bj/ *.user *.sln.docstates .vs/ +.vscode/ # Build results *_i.c @@ -45,6 +46,10 @@ _dotCover* # DevExpress CodeRush src/.cr/ +# Emacs +*~ +\#*\# + # NCrunch *.ncrunch* .*crunch*.local.xml @@ -80,7 +85,6 @@ TestResults [Tt]est[Rr]esult* *.Cache ClientBin -[Ss]tyle[Cc]op.* ~$* *.dbmdl Generated_Code #added for RIA/Silverlight projects @@ -113,26 +117,53 @@ src/UI/.idea/* *log.txt node_modules/ _output* +_artifacts _rawPackage/ _dotTrace* _tests/ +_temp* *.Result.xml +coverage*.xml +coverage*.json setup/Output/ *.~is +.mono -#VS outout folders +# VS outout folders bin obj output/* +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json -#OS X metadata files +# macOS metadata files ._* .DS_Store _start _temp_*/**/* -## Merge any idea folder -*/**/.idea -*.iml +# Windows thumbnail cache files +Thumbs.db + +# AppVeyor +/tools/cake/ +/_artifacts/ + +# Cake +/tools/Addins/* +packages.config.md5sum + +# ignore node_modules symlink +node_modules +node_modules.nosync + +# API doc generation +.config/ + +# Ignore Jetbrains IntelliJ Workspace Directories +.idea/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index bc941f3dd..000000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "src/ExternalModules/CurlSharp"] - path = src/ExternalModules/CurlSharp - url = https://github.com/Sonarr/CurlSharp.git - branch = master diff --git a/.npmrc b/.npmrc deleted file mode 100644 index ad5884817..000000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -save-prefix="" diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d77aa01b5..000000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -language: csharp -solution: src/Lidarr.sln -addons: - apt: - packages: - - nodejs -# - npm apparently not needed anymore. -script: - - ./build.sh - - chmod +x test.sh - - ./test.sh Linux Unit -after_success: - - chmod +x package.sh - - ./package.sh diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..7a36fefe1 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "ms-dotnettools.csdevkit", + "ms-vscode-remote.remote-containers" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..74b8d418b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md + "name": "Run Lidarr", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build dotnet", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/_output/net6.0/Lidarr", + "args": [], + "cwd": "${workspaceFolder}", + // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "console": "integratedTerminal", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..4b3b00b89 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,44 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build dotnet", + "command": "dotnet", + "type": "process", + "args": [ + "msbuild", + "-restore", + "${workspaceFolder}/src/Lidarr.sln", + "-p:GenerateFullPaths=true", + "-p:Configuration=Debug", + "-p:Platform=Posix", + "-consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/src/Lidarr.sln", + "-property:GenerateFullPaths=true", + "-consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/src/Lidarr.sln" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..52e67a0a4 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 05e435d98..36d901f18 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,47 +1,13 @@ -# How to Contribute # +# How to Contribute We're always looking for people to help make Lidarr even better, there are a number of ways to contribute. -## Documentation ## -Setup guides, FAQ, the more information we have on the wiki the better. +This file has been moved to the wiki for the latest details please see the [contributing wiki page](https://wiki.servarr.com/lidarr/contributing). -## Development ## +## Documentation -### Tools required ### -- Visual Studio 2017 -- HTML/Javascript editor of choice (VS Code/Sublime Text/Webstorm/Atom/etc) -- yarn (package manager) -- git +Setup guides, [FAQ](https://wiki.servarr.com/lidarr/faq), the more information we have on the [wiki](https://wiki.servarr.com/lidarr) the better. -### Getting started ### +## Development -1. Fork Lidarr -2. Clone (develop branch) *you may need pull in submodules separately if you client doesn't clone them automatically (CurlSharp)* -3. Run `yarn install` -4. Run `yarn start` - Used to compile the UI components and copy them. - Leave this window open. - If you have gulp globally installed you can use `gulp watch` instead -5. Compile in Visual Studio - -### Contributing Code ### -- If you're adding a new, already requested feature, please comment on [Github Issues](https://github.com/lidarr/Lidarr/issues "Github Issues") so work is not duplicated (If you want to add something not already on there, please talk to us first) -- Rebase from Lidarr's develop branch, don't merge -- Make meaningful commits, or squash them -- Feel free to make a pull request before work is complete, this will let us see where its at and make comments/suggest improvements -- Reach out to us on the forums or on IRC if you have any questions -- Add tests (unit/integration) -- Commit with *nix line endings for consistency (We checkout Windows and commit *nix) -- One feature/bug fix per pull request to keep things clean and easy to understand -- Use 4 spaces instead of tabs, this is the default for VS 2012 and WebStorm (to my knowledge) - -### Pull Requesting ### -- Only make pull requests to develop, never master, if you make a PR to master we'll comment on it and close it -- You're probably going to get some comments or questions from us, they will be to ensure consistency and maintainability -- We'll try to respond to pull requests as soon as possible, if its been a day or two, please reach out to us, we may have missed it -- Each PR should come from its own [feature branch](http://martinfowler.com/bliki/FeatureBranch.html) not develop in your fork, it should have a meaningful branch name (what is being added/fixed) - - new-feature (Good) - - fix-bug (Good) - - patch (Bad) - - develop (Bad) - -If you have any questions about any of this, please let us know. +See the [Wiki Page](https://wiki.servarr.com/lidarr/contributing) diff --git a/Logo/dottrace.svg b/Logo/dottrace.svg new file mode 100644 index 000000000..b879517cd --- /dev/null +++ b/Logo/dottrace.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Logo/jetbrains.svg b/Logo/jetbrains.svg new file mode 100644 index 000000000..75d4d2177 --- /dev/null +++ b/Logo/jetbrains.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Logo/resharper.svg b/Logo/resharper.svg new file mode 100644 index 000000000..24c987a78 --- /dev/null +++ b/Logo/resharper.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Logo/rider.svg b/Logo/rider.svg new file mode 100644 index 000000000..82da35b0b --- /dev/null +++ b/Logo/rider.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + rider + + + + + + + + + + + + + + diff --git a/Logo/webstorm.svg b/Logo/webstorm.svg new file mode 100644 index 000000000..39ab7eb97 --- /dev/null +++ b/Logo/webstorm.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 968555052..7a6da3158 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,17 @@ # Lidarr -[![Build status](https://ci.appveyor.com/api/projects/status/tpm5mj5milne88nc?svg=true)](https://ci.appveyor.com/project/lidarr/lidarr) -[![Codacy Badge](https://api.codacy.com/project/badge/Grade/4e6d014aee9542189b4abb0b1439980f)](https://www.codacy.com/app/Lidarr/Lidarr?utm_source=github.com&utm_medium=referral&utm_content=lidarr/Lidarr&utm_campaign=Badge_Grade) -![Docker Pulls](https://img.shields.io/docker/pulls/linuxserver/lidarr.svg) -[![Backers on Open Collective](https://opencollective.com/lidarr/backers/badge.svg)](#backers) [![Sponsors on Open Collective](https://opencollective.com/lidarr/sponsors/badge.svg)](#sponsors) +[![Build Status](https://dev.azure.com/Lidarr/Lidarr/_apis/build/status/lidarr.Lidarr?branchName=develop)](https://dev.azure.com/Lidarr/Lidarr/_build/latest?definitionId=1&branchName=develop) +[![Translation status](https://translate.servarr.com/widget/servarr/lidarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/?utm_source=widget) +[![Docker Pulls](https://img.shields.io/docker/pulls/linuxserver/lidarr.svg)](https://wiki.servarr.com/lidarr/installation#docker) +![Github Downloads](https://img.shields.io/github/downloads/lidarr/lidarr/total.svg) +[![Backers on Open Collective](https://opencollective.com/lidarr/backers/badge.svg)](#backers) +[![Sponsors on Open Collective](https://opencollective.com/lidarr/sponsors/badge.svg)](#sponsors) Lidarr is a music collection manager for Usenet and BitTorrent users. It can monitor multiple RSS feeds for new tracks from your favorite artists and will grab, sort and rename them. It can also be configured to automatically upgrade the quality of files already downloaded when a better quality format becomes available. +> [!WARNING] +> NOTICE - The Lidarr Metadata Server is recovering and rebuilding the cache which is impacting adding artists, library imports, etc. Please follow [GHI 5498](https://github.com/Lidarr/Lidarr/issues/5498) or see Discord for details. + ## Major Features Include: * Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc. @@ -21,46 +26,52 @@ Lidarr is a music collection manager for Usenet and BitTorrent users. It can mon * Full support for specials and multi-album releases * And a beautiful UI -## Feature Requests - -[![Feature Requests](http://feathub.com/lidarr/Lidarr?format=svg)](http://feathub.com/lidarr/Lidarr) - ## Support -[![Discord](https://img.shields.io/badge/discord-chat-7289DA.svg?maxAge=60)](https://discord.gg/8Y7rDc9) -[![Reddit](https://img.shields.io/badge/reddit-discussion-FF4500.svg?maxAge=60)](https://www.reddit.com/r/lidarr) -[![GitHub](https://img.shields.io/badge/github-issues-red.svg?maxAge=60)](https://github.com/Lidarr/Lidarr/issues) -[![GitHub Wiki](https://img.shields.io/badge/github-wiki-181717.svg?maxAge=60)](https://github.com/Lidarr/Lidarr/wiki) +Note: GitHub Issues are for Bugs and Feature Requests Only + +[![Discord](https://img.shields.io/badge/discord-chat-7289DA.svg?maxAge=60)](https://lidarr.audio/discord) +[![GitHub - Bugs and Feature Requests Only](https://img.shields.io/badge/github-issues-red.svg?maxAge=60)](https://github.com/Lidarr/Lidarr/issues) +[![Wiki](https://img.shields.io/badge/servarr-wiki-181717.svg?maxAge=60)](https://wiki.servarr.com/lidarr) ## Contributors -This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)]. +This project exists thanks to all the people who contribute. [Contribute](CONTRIBUTING.md). - ## Backers -Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/lidarr#backer)] - - +Thank you to all our backers! 🙏 [Become a backer](https://opencollective.com/Lidarr#backer) + ## Sponsors -Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/lidarr#sponsor)] +Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [Become a sponsor](https://opencollective.com/Lidarr#sponsor) - - - - - - - - - - + + +## Mega Sponsors + + +## JetBrains +Thank you to [JetBrains JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools. + +* [ReSharper ReSharper](http://www.jetbrains.com/resharper/) +* [WebStorm WebStorm](http://www.jetbrains.com/webstorm/) +* [Rider Rider](http://www.jetbrains.com/rider/) +* [dotTrace dotTrace](http://www.jetbrains.com/dottrace/) + +## DigitalOcean + +This project is also supported by DigitalOcean +

+ + + +

### License * [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) -* Copyright 2010-2018 +* Copyright 2010-2021 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..765e24fbc --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,8 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report (suspected) security vulnerabilities on Discord (preferred) to +any of the Servarr Dev role holders (red names) or via email: development@servarr.com. You will receive a response from +us within 72 hours. If the issue is confirmed, we will release a patch as soon +as possible depending on complexity/severity. diff --git a/appveyor-package.sh b/appveyor-package.sh deleted file mode 100644 index 6c4454635..000000000 --- a/appveyor-package.sh +++ /dev/null @@ -1,35 +0,0 @@ -#! /bin/bash - -artifactsFolder="./_artifacts"; -artifactsFolderWindows=$artifactsFolder/windows -artifactsFolderLinux=$artifactsFolder/linux -artifactsFolderMacOS=$artifactsFolder/macos -artifactsFolderMacOSApp=$artifactsFolder/macos-app - -PublishArtifacts() -{ - 7z a $artifactsFolder/Lidarr.${APPVEYOR_REPO_BRANCH}.${APPVEYOR_BUILD_VERSION}.windows.zip $artifactsFolderWindows/* - - 7z a $artifactsFolder/Lidarr.${APPVEYOR_REPO_BRANCH}.${APPVEYOR_BUILD_VERSION}.osx-app.zip $artifactsFolderMacOSApp/* - mkdir -p $artifactsFolderMacOSApp/StartScript/Lidarr.app/Contents/MacOS - cp ./osx/Lidarr $artifactsFolderMacOSApp/StartScript/Lidarr.app/Contents/MacOS - 7z a $artifactsFolder/Lidarr.${APPVEYOR_REPO_BRANCH}.${APPVEYOR_BUILD_VERSION}.osx-app.zip $artifactsFolderMacOSApp/StartScript/* - rm -rf $artifactsFolderMacOSApp/StartScript/ - - 7z a -ttar $artifactsFolder/Lidarr.${APPVEYOR_REPO_BRANCH}.${APPVEYOR_BUILD_VERSION}.osx.tar $artifactsFolderMacOS/* - mkdir -p $artifactsFolderMacOS/StartScript/Lidarr - cp ./osx/Lidarr $artifactsFolderMacOS/StartScript/Lidarr/Lidarr - 7z a -ttar $artifactsFolder/Lidarr.${APPVEYOR_REPO_BRANCH}.${APPVEYOR_BUILD_VERSION}.osx.tar $artifactsFolderMacOS/StartScript/* - 7z a -tgzip $artifactsFolder/Lidarr.${APPVEYOR_REPO_BRANCH}.${APPVEYOR_BUILD_VERSION}.osx.tar.gz $artifactsFolder/Lidarr.${APPVEYOR_REPO_BRANCH}.${APPVEYOR_BUILD_VERSION}.osx.tar - rm -f $artifactsFolder/Lidarr.${APPVEYOR_REPO_BRANCH}.${APPVEYOR_BUILD_VERSION}.osx.tar - rm -rf $artifactsFolderMacOS/StartScript/ - - 7z a -ttar $artifactsFolder/Lidarr.${APPVEYOR_REPO_BRANCH}.${APPVEYOR_BUILD_VERSION}.linux.tar $artifactsFolderLinux/* - 7z a -tgzip $artifactsFolder/Lidarr.${APPVEYOR_REPO_BRANCH}.${APPVEYOR_BUILD_VERSION}.linux.tar.gz $artifactsFolder/Lidarr.${APPVEYOR_REPO_BRANCH}.${APPVEYOR_BUILD_VERSION}.linux.tar - rm -f $artifactsFolder/Lidarr.${APPVEYOR_REPO_BRANCH}.${APPVEYOR_BUILD_VERSION}.linux.tar - - ./setup/inno/ISCC.exe "./setup/lidarr.iss" - cp ./setup/output/Lidarr.*windows.exe $artifactsFolder/Lidarr.${APPVEYOR_REPO_BRANCH}.${APPVEYOR_BUILD_VERSION}.windows.exe -} - -PublishArtifacts diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index b0f608423..000000000 --- a/appveyor.yml +++ /dev/null @@ -1,57 +0,0 @@ -version: '0.2.0.{build}' - -image: Visual Studio 2017 - -assembly_info: - patch: true - file: 'src\NzbDrone.Common\Properties\SharedAssemblyInfo.cs' - assembly_version: '{version}' - assembly_file_version: '{version}' - assembly_informational_version: '{version}-rc1' - -environment: - DOTNET_CLI_TELEMETRY_OPTOUT: 1 - nodejs_version: "6" - -install: - - git submodule update --init --recursive - - ps: Install-Product node $env:nodejs_version - -build_script: - - C:\msys64\usr\bin\bash -lc "cd \"$APPVEYOR_BUILD_FOLDER\" && exec ./build.sh - -after_build: - - C:\msys64\usr\bin\bash -lc "cd \"$APPVEYOR_BUILD_FOLDER\" && exec ./appveyor-package.sh - - ps: Get-ChildItem .\_artifacts\*.zip | % { Push-AppveyorArtifact $_.FullName -FileName $_.Name } - - ps: Get-ChildItem .\_artifacts\*.exe | % { Push-AppveyorArtifact $_.FullName -FileName $_.Name } - - ps: Get-ChildItem .\_artifacts\*.tar.gz | % { Push-AppveyorArtifact $_.FullName -FileName $_.Name } - -test_script: - - node --version - - yarn --version - - C:\msys64\usr\bin\bash -lc "cd \"$APPVEYOR_BUILD_FOLDER\" && exec ./test.sh Windows Unit - -cache: - - '%USERPROFILE%\.nuget\packages' - - node_modules -> package.json - -pull_requests: - do_not_increment_build_number: true - -skip_branch_with_pr: true - -on_failure: - - ps: Get-ChildItem .\_artifacts\*.zip | % { Push-AppveyorArtifact $_.FullName -FileName $_.Name } - - ps: Get-ChildItem .\_artifacts\*.exe | % { Push-AppveyorArtifact $_.FullName -FileName $_.Name } - - ps: Get-ChildItem .\_artifacts\*.tar.gz | % { Push-AppveyorArtifact $_.FullName -FileName $_.Name } - -only_commits: - files: - - src/ - - osx/ - - gulp/ - - logo/ - - setup/ - - frontend/ - - appveyor.yml - - build-appveyor.cake diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 000000000..85d13499a --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,1269 @@ +# Starter pipeline +# Start with a minimal pipeline that you can customize to build and deploy your code. +# Add steps that build, run tests, deploy, and more: +# https://aka.ms/yaml + +variables: + outputFolder: './_output' + artifactsFolder: './_artifacts' + testsFolder: './_tests' + yarnCacheFolder: $(Pipeline.Workspace)/.yarn + nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages + majorVersion: '2.13.3' + minorVersion: $[counter('minorVersion', 1076)] + lidarrVersion: '$(majorVersion).$(minorVersion)' + buildName: '$(Build.SourceBranchName).$(lidarrVersion)' + sentryOrg: 'servarr' + sentryUrl: 'https://sentry.servarr.com' + dotnetVersion: '6.0.427' + nodeVersion: '20.X' + innoVersion: '6.2.0' + windowsImage: 'windows-2022' + linuxImage: 'ubuntu-22.04' + macImage: 'macOS-13' + +trigger: + branches: + include: + - develop + - master + paths: + exclude: + - .github + - src/Lidarr.Api.*/openapi.json + +pr: + branches: + include: + - develop + paths: + exclude: + - .github + - src/NzbDrone.Core/Localization/Core + - src/Lidarr.Api.*/openapi.json + +stages: + - stage: Setup + displayName: Setup + jobs: + - job: + displayName: Build Variables + pool: + vmImage: ${{ variables.linuxImage }} + steps: + # Set the build name properly. The 'name' property won't recursively expand so hack here: + - bash: echo "##vso[build.updatebuildnumber]$LIDARRVERSION" + displayName: Set Build Name + - bash: | + if [[ $BUILD_REASON == "PullRequest" ]]; then + git diff origin/develop...HEAD --name-only | grep -E "^(src/|azure-pipelines.yml)" + echo $? > not_backend_update + else + echo 0 > not_backend_update + fi + cat not_backend_update + displayName: Check for Backend File Changes + - publish: not_backend_update + artifact: not_backend_update + displayName: Publish update type + - stage: Build_Backend + displayName: Build Backend + dependsOn: Setup + jobs: + - job: Backend + strategy: + matrix: + Linux: + osName: 'Linux' + imageName: ${{ variables.linuxImage }} + enableAnalysis: 'true' + Mac: + osName: 'Mac' + imageName: ${{ variables.macImage }} + enableAnalysis: 'false' + Windows: + osName: 'Windows' + imageName: ${{ variables.windowsImage }} + enableAnalysis: 'false' + + pool: + vmImage: $(imageName) + variables: + # Disable stylecop here - linting errors get caught by the analyze task + EnableAnalyzers: $(enableAnalysis) + steps: + - checkout: self + submodules: true + fetchDepth: 1 + - task: UseDotNet@2 + displayName: 'Install .net core' + inputs: + version: $(dotnetVersion) + - bash: | + BUNDLEDVERSIONS=${AGENT_TOOLSDIRECTORY}/dotnet/sdk/${DOTNETVERSION}/Microsoft.NETCoreSdk.BundledVersions.props + echo $BUNDLEDVERSIONS + if grep -q freebsd-x64 $BUNDLEDVERSIONS; then + echo "Extra platforms already enabled" + else + echo "Enabling extra platform support" + sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64;linux-x86/' $BUNDLEDVERSIONS + fi + displayName: Enable Extra Platform Support + - bash: ./build.sh --backend --enable-extra-platforms + displayName: Build Lidarr Backend + - bash: | + find ${OUTPUTFOLDER} -type f ! -path "*/publish/*" -exec rm -rf {} \; + find ${OUTPUTFOLDER} -depth -empty -type d -exec rm -r "{}" \; + find ${TESTSFOLDER} -type f ! -path "*/publish/*" -exec rm -rf {} \; + find ${TESTSFOLDER} -depth -empty -type d -exec rm -r "{}" \; + displayName: Clean up intermediate output + condition: and(succeeded(), ne(variables['osName'], 'Windows')) + - publish: $(outputFolder) + artifact: '$(osName)Backend' + displayName: Publish Backend + condition: and(succeeded(), eq(variables['osName'], 'Windows')) + - publish: '$(testsFolder)/net6.0/win-x64/publish' + artifact: win-x64-tests + displayName: Publish win-x64 Test Package + condition: and(succeeded(), eq(variables['osName'], 'Windows')) + - publish: '$(testsFolder)/net6.0/linux-x64/publish' + artifact: linux-x64-tests + displayName: Publish linux-x64 Test Package + condition: and(succeeded(), eq(variables['osName'], 'Windows')) + - publish: '$(testsFolder)/net6.0/linux-x86/publish' + artifact: linux-x86-tests + displayName: Publish linux-x86 Test Package + condition: and(succeeded(), eq(variables['osName'], 'Windows')) + - publish: '$(testsFolder)/net6.0/linux-musl-x64/publish' + artifact: linux-musl-x64-tests + displayName: Publish linux-musl-x64 Test Package + condition: and(succeeded(), eq(variables['osName'], 'Windows')) + - publish: '$(testsFolder)/net6.0/freebsd-x64/publish' + artifact: freebsd-x64-tests + displayName: Publish freebsd-x64 Test Package + condition: and(succeeded(), eq(variables['osName'], 'Windows')) + - publish: '$(testsFolder)/net6.0/osx-x64/publish' + artifact: osx-x64-tests + displayName: Publish osx-x64 Test Package + condition: and(succeeded(), eq(variables['osName'], 'Windows')) + + - stage: Build_Frontend + displayName: Frontend + dependsOn: Setup + jobs: + - job: Build + strategy: + matrix: + Linux: + osName: 'Linux' + imageName: ${{ variables.linuxImage }} + Mac: + osName: 'Mac' + imageName: ${{ variables.macImage }} + Windows: + osName: 'Windows' + imageName: ${{ variables.windowsImage }} + pool: + vmImage: $(imageName) + steps: + - task: UseNode@1 + displayName: Set Node.js version + inputs: + version: $(nodeVersion) + - checkout: self + submodules: true + fetchDepth: 1 + - task: Cache@2 + inputs: + key: 'yarn | "$(osName)" | yarn.lock' + restoreKeys: | + yarn | "$(osName)" + path: $(yarnCacheFolder) + displayName: Cache Yarn packages + - bash: ./build.sh --frontend + displayName: Build Lidarr Frontend + env: + FORCE_COLOR: 0 + YARN_CACHE_FOLDER: $(yarnCacheFolder) + - publish: $(outputFolder) + artifact: '$(osName)Frontend' + displayName: Publish Frontend + condition: and(succeeded(), eq(variables['osName'], 'Windows')) + + - stage: Installer + dependsOn: + - Build_Backend + - Build_Frontend + jobs: + - job: Windows_Installer + displayName: Create Installer + pool: + vmImage: ${{ variables.windowsImage }} + steps: + - checkout: self + fetchDepth: 1 + - task: DownloadPipelineArtifact@2 + inputs: + buildType: 'current' + artifactName: WindowsBackend + targetPath: _output + displayName: Fetch Backend + - task: DownloadPipelineArtifact@2 + inputs: + buildType: 'current' + artifactName: WindowsFrontend + targetPath: _output + displayName: Fetch Frontend + - bash: | + ./build.sh --packages --installer + cp distribution/windows/setup/output/Lidarr.*win-x64.exe ${BUILD_ARTIFACTSTAGINGDIRECTORY}/Lidarr.${BUILDNAME}.windows-core-x64-installer.exe + cp distribution/windows/setup/output/Lidarr.*win-x86.exe ${BUILD_ARTIFACTSTAGINGDIRECTORY}/Lidarr.${BUILDNAME}.windows-core-x86-installer.exe + displayName: Create Installers + - publish: $(Build.ArtifactStagingDirectory) + artifact: 'WindowsInstaller' + displayName: Publish Installer + + - stage: Packages + dependsOn: + - Build_Backend + - Build_Frontend + jobs: + - job: Other_Packages + displayName: Create Standard Packages + pool: + vmImage: ${{ variables.linuxImage }} + steps: + - checkout: self + fetchDepth: 1 + - task: DownloadPipelineArtifact@2 + inputs: + buildType: 'current' + artifactName: WindowsBackend + targetPath: _output + displayName: Fetch Backend + - task: DownloadPipelineArtifact@2 + inputs: + buildType: 'current' + artifactName: WindowsFrontend + targetPath: _output + displayName: Fetch Frontend + - bash: ./build.sh --packages --enable-extra-platforms + displayName: Create Packages + - bash: | + find . -name "fpcalc" -exec chmod a+x {} \; + find . -name "Lidarr" -exec chmod a+x {} \; + find . -name "Lidarr.Update" -exec chmod a+x {} \; + displayName: Set executable bits + - task: ArchiveFiles@2 + displayName: Create win-x64 zip + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/Lidarr.$(buildName).windows-core-x64.zip' + archiveType: 'zip' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/win-x64/net6.0 + - task: ArchiveFiles@2 + displayName: Create win-x86 zip + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/Lidarr.$(buildName).windows-core-x86.zip' + archiveType: 'zip' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/win-x86/net6.0 + - task: ArchiveFiles@2 + displayName: Create osx-x64 app + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/Lidarr.$(buildName).osx-app-core-x64.zip' + archiveType: 'zip' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/osx-x64-app/net6.0 + - task: ArchiveFiles@2 + displayName: Create osx-x64 tar + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/Lidarr.$(buildName).osx-core-x64.tar.gz' + archiveType: 'tar' + tarCompression: 'gz' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/osx-x64/net6.0 + - task: ArchiveFiles@2 + displayName: Create osx-arm64 app + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/Lidarr.$(buildName).osx-app-core-arm64.zip' + archiveType: 'zip' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/osx-arm64-app/net6.0 + - task: ArchiveFiles@2 + displayName: Create osx-arm64 tar + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/Lidarr.$(buildName).osx-core-arm64.tar.gz' + archiveType: 'tar' + tarCompression: 'gz' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/osx-arm64/net6.0 + - task: ArchiveFiles@2 + displayName: Create linux-x64 tar + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/Lidarr.$(buildName).linux-core-x64.tar.gz' + archiveType: 'tar' + tarCompression: 'gz' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/linux-x64/net6.0 + - task: ArchiveFiles@2 + displayName: Create linux-musl-x64 tar + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/Lidarr.$(buildName).linux-musl-core-x64.tar.gz' + archiveType: 'tar' + tarCompression: 'gz' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/linux-musl-x64/net6.0 + - task: ArchiveFiles@2 + displayName: Create linux-x86 tar + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/Lidarr.$(buildName).linux-core-x86.tar.gz' + archiveType: 'tar' + tarCompression: 'gz' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/linux-x86/net6.0 + - task: ArchiveFiles@2 + displayName: Create linux-arm tar + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/Lidarr.$(buildName).linux-core-arm.tar.gz' + archiveType: 'tar' + tarCompression: 'gz' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/linux-arm/net6.0 + - task: ArchiveFiles@2 + displayName: Create linux-musl-arm tar + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/Lidarr.$(buildName).linux-musl-core-arm.tar.gz' + archiveType: 'tar' + tarCompression: 'gz' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/linux-musl-arm/net6.0 + - task: ArchiveFiles@2 + displayName: Create linux-arm64 tar + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/Lidarr.$(buildName).linux-core-arm64.tar.gz' + archiveType: 'tar' + tarCompression: 'gz' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/linux-arm64/net6.0 + - task: ArchiveFiles@2 + displayName: Create linux-musl-arm64 tar + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/Lidarr.$(buildName).linux-musl-core-arm64.tar.gz' + archiveType: 'tar' + tarCompression: 'gz' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/linux-musl-arm64/net6.0 + - task: ArchiveFiles@2 + displayName: Create freebsd-x64 tar + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/Lidarr.$(buildName).freebsd-core-x64.tar.gz' + archiveType: 'tar' + tarCompression: 'gz' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/freebsd-x64/net6.0 + - publish: $(Build.ArtifactStagingDirectory) + artifact: 'Packages' + displayName: Publish Packages + - bash: | + echo "Uploading source maps to sentry" + curl -sL https://sentry.io/get-cli/ | bash + RELEASENAME="Lidarr@${LIDARRVERSION}-${BUILD_SOURCEBRANCHNAME}" + sentry-cli releases new --finalize -p lidarr -p lidarr-ui -p lidarr-update "${RELEASENAME}" + sentry-cli releases -p lidarr-ui files "${RELEASENAME}" upload-sourcemaps _output/UI/ --rewrite + sentry-cli releases set-commits --auto "${RELEASENAME}" + if [[ ${BUILD_SOURCEBRANCH} == "refs/heads/develop" ]]; then + sentry-cli releases deploys "${RELEASENAME}" new -e nightly + else + sentry-cli releases deploys "${RELEASENAME}" new -e production + fi + if [ $? -gt 0 ]; then + echo "##vso[task.logissue type=warning]Error uploading source maps." + fi + exit 0 + displayName: Publish Sentry Source Maps + condition: | + or + ( + and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/develop')), + and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/master')) + ) + env: + SENTRY_AUTH_TOKEN: $(sentryAuthTokenServarr) + SENTRY_ORG: $(sentryOrg) + SENTRY_URL: $(sentryUrl) + + - stage: Unit_Test + displayName: Unit Tests + dependsOn: Build_Backend + condition: succeeded() + + jobs: + - job: Prepare + pool: + vmImage: ${{ variables.linuxImage }} + steps: + - checkout: none + - task: DownloadPipelineArtifact@2 + inputs: + buildType: 'current' + artifactName: 'not_backend_update' + targetPath: '.' + - bash: echo "##vso[task.setvariable variable=backendNotUpdated;isOutput=true]$(cat not_backend_update)" + name: setVar + + - job: Unit + displayName: Unit Native + dependsOn: Prepare + condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0')) + workspace: + clean: all + + strategy: + matrix: + MacCore: + osName: 'Mac' + testName: 'osx-x64' + poolName: 'Azure Pipelines' + imageName: ${{ variables.macImage }} + WindowsCore: + osName: 'Windows' + testName: 'win-x64' + poolName: 'Azure Pipelines' + imageName: ${{ variables.windowsImage }} + LinuxCore: + osName: 'Linux' + testName: 'linux-x64' + poolName: 'Azure Pipelines' + imageName: ${{ variables.linuxImage }} + FreebsdCore: + osName: 'Linux' + testName: 'freebsd-x64' + poolName: 'FreeBSD' + imageName: + + pool: + name: $(poolName) + vmImage: $(imageName) + + steps: + - checkout: none + - task: UseDotNet@2 + displayName: 'Install .net core' + inputs: + version: $(dotnetVersion) + condition: ne(variables['poolName'], 'FreeBSD') + - task: DownloadPipelineArtifact@2 + displayName: Download Test Artifact + inputs: + buildType: 'current' + artifactName: '$(testName)-tests' + targetPath: $(testsFolder) + - powershell: Set-Service SCardSvr -StartupType Manual + displayName: Enable Windows Test Service + condition: and(succeeded(), eq(variables['osName'], 'Windows')) + - bash: | + chmod a+x _tests/fpcalc + displayName: Make fpcalc Executable + condition: and(succeeded(), and(ne(variables['osName'], 'Windows'), ne(variables['poolName'], 'FreeBSD'))) + - bash: find ${TESTSFOLDER} -name "Lidarr.Test.Dummy" -exec chmod a+x {} \; + displayName: Make Test Dummy Executable + condition: and(succeeded(), ne(variables['osName'], 'Windows')) + - bash: | + chmod a+x ${TESTSFOLDER}/test.sh + ${TESTSFOLDER}/test.sh ${OSNAME} Unit Test + displayName: Run Tests + env: + TEST_DIR: $(Build.SourcesDirectory)/_tests + - task: PublishTestResults@2 + displayName: Publish Test Results + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '**/TestResult.xml' + testRunTitle: '$(testName) Unit Tests' + failTaskOnFailedTests: true + + - job: Unit_Docker + displayName: Unit Docker + dependsOn: Prepare + condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0')) + strategy: + matrix: + alpine: + testName: 'Musl Net Core' + artifactName: linux-musl-x64-tests + containerImage: ghcr.io/servarr/testimages:alpine + linux-x86: + testName: 'linux-x86' + artifactName: linux-x86-tests + containerImage: ghcr.io/servarr/testimages:linux-x86 + + pool: + vmImage: ${{ variables.linuxImage }} + + container: $[ variables['containerImage'] ] + + timeoutInMinutes: 10 + + steps: + - task: UseDotNet@2 + displayName: 'Install .NET' + inputs: + version: $(dotnetVersion) + condition: and(succeeded(), ne(variables['testName'], 'linux-x86')) + - bash: | + SDKURL=$(curl -s https://api.github.com/repos/Servarr/dotnet-linux-x86/releases | jq -rc '.[].assets[].browser_download_url' | grep sdk-${DOTNETVERSION}.*gz$) + curl -fsSL $SDKURL | tar xzf - -C /opt/dotnet + displayName: 'Install .NET' + condition: and(succeeded(), eq(variables['testName'], 'linux-x86')) + - checkout: none + - task: DownloadPipelineArtifact@2 + displayName: Download Test Artifact + inputs: + buildType: 'current' + artifactName: $(artifactName) + targetPath: $(testsFolder) + - bash: | + chmod a+x _tests/fpcalc + displayName: Make fpcalc Executable + condition: and(succeeded(), ne(variables['osName'], 'Windows')) + - bash: find ${TESTSFOLDER} -name "Lidarr.Test.Dummy" -exec chmod a+x {} \; + displayName: Make Test Dummy Executable + condition: and(succeeded(), ne(variables['osName'], 'Windows')) + - bash: | + chmod a+x ${TESTSFOLDER}/test.sh + ls -lR ${TESTSFOLDER} + ${TESTSFOLDER}/test.sh Linux Unit Test + displayName: Run Tests + - task: PublishTestResults@2 + displayName: Publish Test Results + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '**/TestResult.xml' + testRunTitle: '$(testName) Unit Tests' + failTaskOnFailedTests: true + + - job: Unit_LinuxCore_Postgres14 + displayName: Unit Native LinuxCore with Postgres14 Database + dependsOn: Prepare + condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0')) + variables: + pattern: 'Lidarr.*.linux-core-x64.tar.gz' + artifactName: linux-x64-tests + Lidarr__Postgres__Host: 'localhost' + Lidarr__Postgres__Port: '5432' + Lidarr__Postgres__User: 'lidarr' + Lidarr__Postgres__Password: 'lidarr' + + pool: + vmImage: ${{ variables.linuxImage }} + + timeoutInMinutes: 10 + + steps: + - task: UseDotNet@2 + displayName: 'Install .net core' + inputs: + version: $(dotnetVersion) + - checkout: none + - task: DownloadPipelineArtifact@2 + displayName: Download Test Artifact + inputs: + buildType: 'current' + artifactName: $(artifactName) + targetPath: $(testsFolder) + - bash: | + chmod a+x _tests/fpcalc + displayName: Make fpcalc Executable + condition: and(succeeded(), ne(variables['osName'], 'Windows')) + - bash: find ${TESTSFOLDER} -name "Lidarr.Test.Dummy" -exec chmod a+x {} \; + displayName: Make Test Dummy Executable + condition: and(succeeded(), ne(variables['osName'], 'Windows')) + - bash: | + docker run -d --name=postgres14 \ + -e POSTGRES_PASSWORD=lidarr \ + -e POSTGRES_USER=lidarr \ + -p 5432:5432/tcp \ + -v /usr/share/zoneinfo/America/Chicago:/etc/localtime:ro \ + postgres:14 + displayName: Start postgres + - bash: | + chmod a+x ${TESTSFOLDER}/test.sh + ls -lR ${TESTSFOLDER} + ${TESTSFOLDER}/test.sh Linux Unit Test + displayName: Run Tests + - task: PublishTestResults@2 + displayName: Publish Test Results + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '**/TestResult.xml' + testRunTitle: 'LinuxCore Postgres14 Unit Tests' + failTaskOnFailedTests: true + + - job: Unit_LinuxCore_Postgres15 + displayName: Unit Native LinuxCore with Postgres15 Database + dependsOn: Prepare + condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0')) + variables: + pattern: 'Lidarr.*.linux-core-x64.tar.gz' + artifactName: linux-x64-tests + Lidarr__Postgres__Host: 'localhost' + Lidarr__Postgres__Port: '5432' + Lidarr__Postgres__User: 'lidarr' + Lidarr__Postgres__Password: 'lidarr' + + pool: + vmImage: ${{ variables.linuxImage }} + + timeoutInMinutes: 10 + + steps: + - task: UseDotNet@2 + displayName: 'Install .net core' + inputs: + version: $(dotnetVersion) + - checkout: none + - task: DownloadPipelineArtifact@2 + displayName: Download Test Artifact + inputs: + buildType: 'current' + artifactName: $(artifactName) + targetPath: $(testsFolder) + - bash: | + chmod a+x _tests/fpcalc + displayName: Make fpcalc Executable + condition: and(succeeded(), ne(variables['osName'], 'Windows')) + - bash: find ${TESTSFOLDER} -name "Lidarr.Test.Dummy" -exec chmod a+x {} \; + displayName: Make Test Dummy Executable + condition: and(succeeded(), ne(variables['osName'], 'Windows')) + - bash: | + docker run -d --name=postgres15 \ + -e POSTGRES_PASSWORD=lidarr \ + -e POSTGRES_USER=lidarr \ + -p 5432:5432/tcp \ + -v /usr/share/zoneinfo/America/Chicago:/etc/localtime:ro \ + postgres:15 + displayName: Start postgres + - bash: | + chmod a+x ${TESTSFOLDER}/test.sh + ls -lR ${TESTSFOLDER} + ${TESTSFOLDER}/test.sh Linux Unit Test + displayName: Run Tests + - task: PublishTestResults@2 + displayName: Publish Test Results + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '**/TestResult.xml' + testRunTitle: 'LinuxCore Postgres15 Unit Tests' + failTaskOnFailedTests: true + + - stage: Integration + displayName: Integration + dependsOn: Packages + + jobs: + - job: Prepare + pool: + vmImage: ${{ variables.linuxImage }} + steps: + - checkout: none + - task: DownloadPipelineArtifact@2 + inputs: + buildType: 'current' + artifactName: 'not_backend_update' + targetPath: '.' + - bash: echo "##vso[task.setvariable variable=backendNotUpdated;isOutput=true]$(cat not_backend_update)" + name: setVar + + - job: Integration_Native + displayName: Integration Native + dependsOn: Prepare + condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0')) + strategy: + matrix: + MacCore: + osName: 'Mac' + testName: 'osx-x64' + imageName: ${{ variables.macImage }} + pattern: 'Lidarr.*.osx-core-x64.tar.gz' + WindowsCore: + osName: 'Windows' + testName: 'win-x64' + imageName: ${{ variables.windowsImage }} + pattern: 'Lidarr.*.windows-core-x64.zip' + LinuxCore: + osName: 'Linux' + testName: 'linux-x64' + imageName: ${{ variables.linuxImage }} + pattern: 'Lidarr.*.linux-core-x64.tar.gz' + + pool: + vmImage: $(imageName) + + steps: + - task: UseDotNet@2 + displayName: 'Install .net core' + inputs: + version: $(dotnetVersion) + - checkout: none + - task: DownloadPipelineArtifact@2 + displayName: Download Test Artifact + inputs: + buildType: 'current' + artifactName: '$(testName)-tests' + targetPath: $(testsFolder) + - task: DownloadPipelineArtifact@2 + displayName: Download Build Artifact + inputs: + buildType: 'current' + artifactName: Packages + itemPattern: '**/$(pattern)' + targetPath: $(Build.ArtifactStagingDirectory) + - task: ExtractFiles@1 + inputs: + archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' + destinationFolder: '$(Build.ArtifactStagingDirectory)/bin' + displayName: Extract Package + - bash: | + mkdir -p ./bin/ + cp -r -v ${BUILD_ARTIFACTSTAGINGDIRECTORY}/bin/Lidarr/. ./bin/ + displayName: Move Package Contents + - bash: | + chmod a+x ${TESTSFOLDER}/test.sh + ${TESTSFOLDER}/test.sh ${OSNAME} Integration Test + displayName: Run Integration Tests + - task: PublishTestResults@2 + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '**/TestResult.xml' + testRunTitle: '$(testName) Integration Tests' + failTaskOnFailedTests: true + displayName: Publish Test Results + + - job: Integration_LinuxCore_Postgres14 + displayName: Integration Native LinuxCore with Postgres14 Database + dependsOn: Prepare + condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0')) + variables: + pattern: 'Lidarr.*.linux-core-x64.tar.gz' + Lidarr__Postgres__Host: 'localhost' + Lidarr__Postgres__Port: '5432' + Lidarr__Postgres__User: 'lidarr' + Lidarr__Postgres__Password: 'lidarr' + + pool: + vmImage: ${{ variables.linuxImage }} + + steps: + - task: UseDotNet@2 + displayName: 'Install .net core' + inputs: + version: $(dotnetVersion) + - checkout: none + - task: DownloadPipelineArtifact@2 + displayName: Download Test Artifact + inputs: + buildType: 'current' + artifactName: 'linux-x64-tests' + targetPath: $(testsFolder) + - task: DownloadPipelineArtifact@2 + displayName: Download Build Artifact + inputs: + buildType: 'current' + artifactName: Packages + itemPattern: '**/$(pattern)' + targetPath: $(Build.ArtifactStagingDirectory) + - task: ExtractFiles@1 + inputs: + archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' + destinationFolder: '$(Build.ArtifactStagingDirectory)/bin' + displayName: Extract Package + - bash: | + mkdir -p ./bin/ + cp -r -v ${BUILD_ARTIFACTSTAGINGDIRECTORY}/bin/Lidarr/. ./bin/ + displayName: Move Package Contents + - bash: | + docker run -d --name=postgres14 \ + -e POSTGRES_PASSWORD=lidarr \ + -e POSTGRES_USER=lidarr \ + -p 5432:5432/tcp \ + -v /usr/share/zoneinfo/America/Chicago:/etc/localtime:ro \ + postgres:14 + displayName: Start postgres + - bash: | + chmod a+x ${TESTSFOLDER}/test.sh + ${TESTSFOLDER}/test.sh Linux Integration Test + displayName: Run Integration Tests + - task: PublishTestResults@2 + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '**/TestResult.xml' + testRunTitle: 'Integration LinuxCore Postgres14 Database Integration Tests' + failTaskOnFailedTests: true + displayName: Publish Test Results + + + - job: Integration_LinuxCore_Postgres15 + displayName: Integration Native LinuxCore with Postgres Database + dependsOn: Prepare + condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0')) + variables: + pattern: 'Lidarr.*.linux-core-x64.tar.gz' + Lidarr__Postgres__Host: 'localhost' + Lidarr__Postgres__Port: '5432' + Lidarr__Postgres__User: 'lidarr' + Lidarr__Postgres__Password: 'lidarr' + + pool: + vmImage: ${{ variables.linuxImage }} + + steps: + - task: UseDotNet@2 + displayName: 'Install .net core' + inputs: + version: $(dotnetVersion) + - checkout: none + - task: DownloadPipelineArtifact@2 + displayName: Download Test Artifact + inputs: + buildType: 'current' + artifactName: 'linux-x64-tests' + targetPath: $(testsFolder) + - task: DownloadPipelineArtifact@2 + displayName: Download Build Artifact + inputs: + buildType: 'current' + artifactName: Packages + itemPattern: '**/$(pattern)' + targetPath: $(Build.ArtifactStagingDirectory) + - task: ExtractFiles@1 + inputs: + archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' + destinationFolder: '$(Build.ArtifactStagingDirectory)/bin' + displayName: Extract Package + - bash: | + mkdir -p ./bin/ + cp -r -v ${BUILD_ARTIFACTSTAGINGDIRECTORY}/bin/Lidarr/. ./bin/ + displayName: Move Package Contents + - bash: | + docker run -d --name=postgres15 \ + -e POSTGRES_PASSWORD=lidarr \ + -e POSTGRES_USER=lidarr \ + -p 5432:5432/tcp \ + -v /usr/share/zoneinfo/America/Chicago:/etc/localtime:ro \ + postgres:15 + displayName: Start postgres + - bash: | + chmod a+x ${TESTSFOLDER}/test.sh + ${TESTSFOLDER}/test.sh Linux Integration Test + displayName: Run Integration Tests + - task: PublishTestResults@2 + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '**/TestResult.xml' + testRunTitle: 'Integration LinuxCore Postgres15 Database Integration Tests' + failTaskOnFailedTests: true + displayName: Publish Test Results + + - job: Integration_FreeBSD + displayName: Integration Native FreeBSD + dependsOn: Prepare + condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0')) + workspace: + clean: all + variables: + pattern: 'Lidarr.*.freebsd-core-x64.tar.gz' + pool: + name: 'FreeBSD' + + steps: + - checkout: none + - task: DownloadPipelineArtifact@2 + displayName: Download Test Artifact + inputs: + buildType: 'current' + artifactName: 'freebsd-x64-tests' + targetPath: $(testsFolder) + - task: DownloadPipelineArtifact@2 + displayName: Download Build Artifact + inputs: + buildType: 'current' + artifactName: Packages + itemPattern: '**/$(pattern)' + targetPath: $(Build.ArtifactStagingDirectory) + - bash: | + mkdir -p ${BUILD_ARTIFACTSTAGINGDIRECTORY}/bin + tar xf ${BUILD_ARTIFACTSTAGINGDIRECTORY}/$(pattern) -C ${BUILD_ARTIFACTSTAGINGDIRECTORY}/bin + displayName: Extract Package + - bash: | + mkdir -p ./bin/ + cp -r -v ${BUILD_ARTIFACTSTAGINGDIRECTORY}/bin/Lidarr/. ./bin/ + displayName: Move Package Contents + - bash: | + chmod a+x ${TESTSFOLDER}/test.sh + ${TESTSFOLDER}/test.sh Linux Integration Test + displayName: Run Integration Tests + - task: PublishTestResults@2 + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '**/TestResult.xml' + testRunTitle: 'FreeBSD Integration Tests' + failTaskOnFailedTests: true + displayName: Publish Test Results + + - job: Integration_Docker + displayName: Integration Docker + dependsOn: Prepare + condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0')) + strategy: + matrix: + alpine: + testName: 'linux-musl-x64' + artifactName: linux-musl-x64-tests + containerImage: ghcr.io/servarr/testimages:alpine + pattern: 'Lidarr.*.linux-musl-core-x64.tar.gz' + linux-x86: + testName: 'linux-x86' + artifactName: linux-x86-tests + containerImage: ghcr.io/servarr/testimages:linux-x86 + pattern: 'Lidarr.*.linux-core-x86.tar.gz' + pool: + vmImage: ${{ variables.linuxImage }} + + container: $[ variables['containerImage'] ] + + timeoutInMinutes: 15 + + steps: + - task: UseDotNet@2 + displayName: 'Install .NET' + inputs: + version: $(dotnetVersion) + condition: and(succeeded(), ne(variables['testName'], 'linux-x86')) + - bash: | + SDKURL=$(curl -s https://api.github.com/repos/Servarr/dotnet-linux-x86/releases | jq -rc '.[].assets[].browser_download_url' | grep sdk-${DOTNETVERSION}.*gz$) + curl -fsSL $SDKURL | tar xzf - -C /opt/dotnet + displayName: 'Install .NET' + condition: and(succeeded(), eq(variables['testName'], 'linux-x86')) + - checkout: none + - task: DownloadPipelineArtifact@2 + displayName: Download Test Artifact + inputs: + buildType: 'current' + artifactName: $(artifactName) + targetPath: $(testsFolder) + - task: DownloadPipelineArtifact@2 + displayName: Download Build Artifact + inputs: + buildType: 'current' + artifactName: Packages + itemPattern: '**/$(pattern)' + targetPath: $(Build.ArtifactStagingDirectory) + - task: ExtractFiles@1 + inputs: + archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' + destinationFolder: '$(Build.ArtifactStagingDirectory)/bin' + displayName: Extract Package + - bash: | + mkdir -p ./bin/ + cp -r -v ${BUILD_ARTIFACTSTAGINGDIRECTORY}/bin/Lidarr/. ./bin/ + displayName: Move Package Contents + - bash: | + chmod a+x ${TESTSFOLDER}/test.sh + ${TESTSFOLDER}/test.sh Linux Integration Test + displayName: Run Integration Tests + - task: PublishTestResults@2 + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '**/TestResult.xml' + testRunTitle: '$(testName) Integration Tests' + failTaskOnFailedTests: true + displayName: Publish Test Results + + - stage: Automation + displayName: Automation + dependsOn: Packages + + jobs: + - job: Automation + strategy: + matrix: + Linux: + osName: 'Linux' + artifactName: 'linux-x64' + imageName: ${{ variables.linuxImage }} + pattern: 'Lidarr.*.linux-core-x64.tar.gz' + failBuild: true + Mac: + osName: 'Mac' + artifactName: 'osx-x64' + imageName: ${{ variables.macImage }} + pattern: 'Lidarr.*.osx-core-x64.tar.gz' + failBuild: true + Windows: + osName: 'Windows' + artifactName: 'win-x64' + imageName: ${{ variables.windowsImage }} + pattern: 'Lidarr.*.windows-core-x64.zip' + failBuild: true + + pool: + vmImage: $(imageName) + + steps: + - task: UseDotNet@2 + displayName: 'Install .net core' + inputs: + version: $(dotnetVersion) + - checkout: none + - task: DownloadPipelineArtifact@2 + displayName: Download Test Artifact + inputs: + buildType: 'current' + artifactName: '$(artifactName)-tests' + targetPath: $(testsFolder) + - task: DownloadPipelineArtifact@2 + displayName: Download Build Artifact + inputs: + buildType: 'current' + artifactName: Packages + itemPattern: '**/$(pattern)' + targetPath: $(Build.ArtifactStagingDirectory) + - task: ExtractFiles@1 + inputs: + archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' + destinationFolder: '$(Build.ArtifactStagingDirectory)/bin' + displayName: Extract Package + - bash: | + mkdir -p ./bin/ + cp -r -v ${BUILD_ARTIFACTSTAGINGDIRECTORY}/bin/Lidarr/. ./bin/ + displayName: Move Package Contents + - bash: | + chmod a+x ${TESTSFOLDER}/test.sh + ${TESTSFOLDER}/test.sh ${OSNAME} Automation Test + displayName: Run Automation Tests + - task: CopyFiles@2 + displayName: 'Copy Screenshot to: $(Build.ArtifactStagingDirectory)' + inputs: + SourceFolder: '$(Build.SourcesDirectory)' + Contents: | + **/*_test_screenshot.png + TargetFolder: '$(Build.ArtifactStagingDirectory)/screenshots' + - publish: $(Build.ArtifactStagingDirectory)/screenshots + artifact: '$(osName)AutomationScreenshots' + displayName: Publish Screenshot Bundle + condition: and(succeeded(), eq(variables['System.JobAttempt'], '1')) + - task: PublishTestResults@2 + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '**/TestResult.xml' + testRunTitle: '$(osName) Automation Tests' + failTaskOnFailedTests: $(failBuild) + displayName: Publish Test Results + + - stage: Analyze + dependsOn: + - Setup + displayName: Analyze + + jobs: + - job: Prepare + pool: + vmImage: ${{ variables.linuxImage }} + steps: + - checkout: none + - task: DownloadPipelineArtifact@2 + inputs: + buildType: 'current' + artifactName: 'not_backend_update' + targetPath: '.' + - bash: echo "##vso[task.setvariable variable=backendNotUpdated;isOutput=true]$(cat not_backend_update)" + name: setVar + + - job: Lint_Frontend + displayName: Lint Frontend + strategy: + matrix: + Linux: + osName: 'Linux' + imageName: ${{ variables.linuxImage }} + Windows: + osName: 'Windows' + imageName: ${{ variables.windowsImage }} + pool: + vmImage: $(imageName) + steps: + - task: UseNode@1 + displayName: Set Node.js version + inputs: + version: $(nodeVersion) + - checkout: self + submodules: true + fetchDepth: 1 + - task: Cache@2 + inputs: + key: 'yarn | "$(osName)" | yarn.lock' + restoreKeys: | + yarn | "$(osName)" + path: $(yarnCacheFolder) + displayName: Cache Yarn packages + - bash: ./build.sh --lint + displayName: Lint Lidarr Frontend + env: + FORCE_COLOR: 0 + YARN_CACHE_FOLDER: $(yarnCacheFolder) + + - job: Analyze_Frontend + displayName: Frontend + condition: eq(variables['System.PullRequest.IsFork'], 'False') + pool: + vmImage: ${{ variables.windowsImage }} + steps: + - checkout: self # Need history for Sonar analysis + - task: SonarCloudPrepare@3 + env: + SONAR_SCANNER_OPTS: '' + inputs: + SonarCloud: 'SonarCloud' + organization: 'lidarr' + scannerMode: 'cli' + configMode: 'manual' + cliProjectKey: 'lidarr_Lidarr.UI' + cliProjectName: 'LidarrUI' + cliProjectVersion: '$(lidarrVersion)' + cliSources: './frontend' + - task: SonarCloudAnalyze@3 + + - job: Api_Docs + displayName: API Docs + dependsOn: Prepare + condition: | + and + ( + and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/develop')), + and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0')) + ) + + pool: + vmImage: ${{ variables.windowsImage }} + + steps: + - task: UseDotNet@2 + displayName: 'Install .net core' + inputs: + version: $(dotnetVersion) + - checkout: self + submodules: true + persistCredentials: true + fetchDepth: 1 + - bash: ./docs.sh Windows + displayName: Create openapi.json + - bash: | + git config --global user.email "development@lidarr.audio" + git config --global user.name "Servarr" + git checkout -b api-docs + git add . + if git status | grep -q modified + then + git commit -am 'Automated API Docs update' + git push -f --set-upstream origin api-docs + curl -X POST -H "Authorization: token ${GITHUBTOKEN}" -H "Accept: application/vnd.github.v3+json" https://api.github.com/repos/lidarr/lidarr/pulls -d '{"head":"api-docs","base":"develop","title":"Update API docs"}' + else + echo "No changes since last run" + fi + displayName: Commit API Doc Change + continueOnError: true + env: + GITHUBTOKEN: $(githubToken) + - task: CopyFiles@2 + displayName: 'Copy openapi.json to: $(Build.ArtifactStagingDirectory)' + inputs: + SourceFolder: '$(Build.SourcesDirectory)' + Contents: | + **/*openapi.json + TargetFolder: '$(Build.ArtifactStagingDirectory)/api_docs' + - publish: $(Build.ArtifactStagingDirectory)/api_docs + artifact: 'APIDocs' + displayName: Publish API Docs Bundle + condition: and(succeeded(), eq(variables['System.JobAttempt'], '1')) + + - job: Analyze_Backend + displayName: Backend + dependsOn: Prepare + condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0')) + + variables: + disable.coverage.autogenerate: 'true' + EnableAnalyzers: 'false' + + pool: + vmImage: ${{ variables.windowsImage }} + + steps: + - task: UseDotNet@2 + displayName: 'Install .net core' + inputs: + version: $(dotnetVersion) + - checkout: self # Need history for Sonar analysis + submodules: true + - powershell: Set-Service SCardSvr -StartupType Manual + displayName: Enable Windows Test Service + - task: SonarCloudPrepare@3 + condition: eq(variables['System.PullRequest.IsFork'], 'False') + inputs: + SonarCloud: 'SonarCloud' + organization: 'lidarr' + scannerMode: 'dotnet' + projectKey: 'lidarr_Lidarr' + projectName: 'Lidarr' + projectVersion: '$(lidarrVersion)' + extraProperties: | + sonar.exclusions=**/obj/**,**/*.dll,**/NzbDrone.Core.Test/Files/**/*,./frontend/**,**/ExternalModules/**,./src/Libraries/** + sonar.coverage.exclusions=**/Lidarr.Api.V1/**/* + sonar.cs.opencover.reportsPaths=$(Build.SourcesDirectory)/CoverageResults/**/coverage.opencover.xml + sonar.cs.nunit.reportsPaths=$(Build.SourcesDirectory)/TestResult.xml + - bash: | + ./build.sh --backend -f net6.0 -r win-x64 + TEST_DIR=_tests/net6.0/win-x64/publish/ ./test.sh Windows Unit Coverage + displayName: Coverage Unit Tests + - task: SonarCloudAnalyze@3 + condition: eq(variables['System.PullRequest.IsFork'], 'False') + displayName: Publish SonarCloud Results + - task: reportgenerator@5.3.11 + displayName: Generate Coverage Report + inputs: + reports: '$(Build.SourcesDirectory)/CoverageResults/**/coverage.opencover.xml' + targetdir: '$(Build.SourcesDirectory)/CoverageResults/combined' + reporttypes: 'HtmlInline_AzurePipelines;Cobertura;Badges' + publishCodeCoverageResults: true + + - stage: Report_Out + dependsOn: + - Analyze + - Unit_Test + - Integration + - Automation + condition: eq(variables['system.pullrequest.isfork'], false) + displayName: Build Status Report + jobs: + - job: + displayName: Discord Notification + pool: + vmImage: ${{ variables.linuxImage }} + steps: + - task: DownloadPipelineArtifact@2 + continueOnError: true + displayName: Download Screenshot Artifact + inputs: + buildType: 'current' + artifactName: 'WindowsAutomationScreenshots' + targetPath: $(Build.SourcesDirectory) + - checkout: none + - pwsh: | + iex ((New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/Servarr/AzureDiscordNotify/master/DiscordNotify.ps1')) + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + DISCORDCHANNELID: $(discordChannelId) + DISCORDWEBHOOKKEY: $(discordWebhookKey) + DISCORDTHREADID: $(discordThreadId) + diff --git a/build.sh b/build.sh index a350b02ef..ca2ab01ef 100755 --- a/build.sh +++ b/build.sh @@ -1,34 +1,9 @@ -#! /bin/bash -msBuild='/c/Program Files (x86)/MSBuild/14.0/Bin' -outputFolder='./_output' -outputFolderLinux='./_output_linux' -outputFolderMacOS='./_output_macos' -outputFolderMacOSApp='./_output_macos_app' -testPackageFolder='./_tests/' -testSearchPattern='*.Test/bin/x86/Release' -sourceFolder='./src' -slnFile=$sourceFolder/Lidarr.sln -updateFolder=$outputFolder/Lidarr.Update -updateFolderMono=$outputFolderLinux/Lidarr.Update +#! /usr/bin/env bash +set -e -#Artifact variables -artifactsFolder="./_artifacts"; -artifactsFolderWindows=$artifactsFolder/windows -artifactsFolderLinux=$artifactsFolder/linux -artifactsFolderMacOS=$artifactsFolder/macos -artifactsFolderMacOSApp=$artifactsFolder/macos-app - -nuget='tools/nuget/nuget.exe'; -CheckExitCode() -{ - "$@" - local status=$? - if [ $status -ne 0 ]; then - echo "error with $1" >&2 - exit 1 - fi - return $status -} +outputFolder='_output' +testPackageFolder='_tests' +artifactsFolder="_artifacts"; ProgressStart() { @@ -40,62 +15,47 @@ ProgressEnd() echo "Finish '$1'" } -CleanFolder() +UpdateVersionNumber() { - local path=$1 - local keepConfigFiles=$2 - - find $path -name "*.transform" -exec rm "{}" \; - - if [ $keepConfigFiles != true ] ; then - find $path -name "*.dll.config" -exec rm "{}" \; + if [ "$LIDARRVERSION" != "" ]; then + echo "Updating Version Info" + sed -i'' -e "s/[0-9.*]\+<\/AssemblyVersion>/$LIDARRVERSION<\/AssemblyVersion>/g" src/Directory.Build.props + sed -i'' -e "s/[\$()A-Za-z-]\+<\/AssemblyConfiguration>/${BUILD_SOURCEBRANCHNAME}<\/AssemblyConfiguration>/g" src/Directory.Build.props + sed -i'' -e "s/10.0.0.0<\/string>/$LIDARRVERSION<\/string>/g" distribution/osx/Lidarr.app/Contents/Info.plist fi - - echo "Removing FluentValidation.Resources files" - find $path -name "FluentValidation.resources.dll" -exec rm "{}" \; - find $path -name "App.config" -exec rm "{}" \; - - echo "Removing vshost files" - find $path -name "*.vshost.exe" -exec rm "{}" \; - - echo "Removing dylib files" - find $path -name "*.dylib" -exec rm "{}" \; - - echo "Removing Empty folders" - find $path -depth -empty -type d -exec rm -r "{}" \; } -AddJsonNet() +EnableExtraPlatformsInSDK() { - rm $outputFolder/Newtonsoft.Json.* - cp $sourceFolder/packages/Newtonsoft.Json.*/lib/net35/*.dll $outputFolder - cp $sourceFolder/packages/Newtonsoft.Json.*/lib/net35/*.dll $updateFolder + SDK_PATH=$(dotnet --list-sdks | grep -P '6\.\d\.\d+' | head -1 | sed 's/\(6\.[0-9]*\.[0-9]*\).*\[\(.*\)\]/\2\/\1/g') + BUNDLEDVERSIONS="${SDK_PATH}/Microsoft.NETCoreSdk.BundledVersions.props" + if grep -q freebsd-x64 $BUNDLEDVERSIONS; then + echo "Extra platforms already enabled" + else + echo "Enabling extra platform support" + sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64;linux-x86/' $BUNDLEDVERSIONS + fi } -BuildWithMSBuild() +EnableExtraPlatforms() { - export PATH=$msBuild:$PATH - CheckExitCode MSBuild.exe $slnFile //t:Clean //m - $nuget restore $slnFile - CheckExitCode MSBuild.exe $slnFile //p:Configuration=Release //p:Platform=x86 //t:Build //m //p:AllowedReferenceRelatedFileExtensions=.pdb -} - -BuildWithXbuild() -{ - export MONO_IOMAP=case - CheckExitCode xbuild /t:Clean $slnFile - mono $nuget restore $slnFile - CheckExitCode xbuild /p:Configuration=Release /p:Platform=x86 /t:Build /p:AllowedReferenceRelatedFileExtensions=.pdb $slnFile + if grep -qv freebsd-x64 src/Directory.Build.props; then + sed -i'' -e "s^\(.*\)^\1;freebsd-x64;linux-x86^g" src/Directory.Build.props + fi } LintUI() { ProgressStart 'ESLint' - CheckExitCode yarn eslint + yarn lint ProgressEnd 'ESLint' ProgressStart 'Stylelint' - CheckExitCode yarn stylelint + if [ "$os" = "windows" ]; then + yarn stylelint-windows + else + yarn stylelint-linux + fi ProgressEnd 'Stylelint' } @@ -104,216 +64,379 @@ Build() ProgressStart 'Build' rm -rf $outputFolder + rm -rf $testPackageFolder - if [ $runtime = "dotnet" ] ; then - BuildWithMSBuild + slnFile=src/Lidarr.sln + + if [ $os = "windows" ]; then + platform=Windows else - BuildWithXbuild + platform=Posix fi - CleanFolder $outputFolder false + dotnet clean $slnFile -c Debug + dotnet clean $slnFile -c Release - AddJsonNet - - echo "Removing Mono.Posix.dll" - rm $outputFolder/Mono.Posix.dll + if [[ -z "$RID" || -z "$FRAMEWORK" ]]; + then + dotnet msbuild -restore $slnFile -p:Configuration=Release -p:Platform=$platform -t:PublishAllRids + else + dotnet msbuild -restore $slnFile -p:Configuration=Release -p:Platform=$platform -p:RuntimeIdentifiers=$RID -t:PublishAllRids + fi ProgressEnd 'Build' } -RunGulp() +YarnInstall() { ProgressStart 'yarn install' - yarn install - #npm-cache install npm || CheckExitCode npm install --no-optional --no-bin-links + yarn install --frozen-lockfile --network-timeout 120000 ProgressEnd 'yarn install' - - LintUI - - ProgressStart 'Running gulp' - CheckExitCode yarn run build --production - ProgressEnd 'Running gulp' } -CreateMdbs() +RunWebpack() { - local path=$1 - if [ $runtime = "dotnet" ] ; then - local pdbFiles=( $(find $path -name "*.pdb") ) - for filename in "${pdbFiles[@]}" - do - if [ -e ${filename%.pdb}.dll ] ; then - tools/pdb2mdb/pdb2mdb.exe ${filename%.pdb}.dll - fi - if [ -e ${filename%.pdb}.exe ] ; then - tools/pdb2mdb/pdb2mdb.exe ${filename%.pdb}.exe - fi - done - fi + ProgressStart 'Running webpack' + yarn run build --env production + ProgressEnd 'Running webpack' } -PackageMono() +PackageFiles() { - ProgressStart 'Creating Mono Package' + local folder="$1" + local framework="$2" + local runtime="$3" - rm -rf $outputFolderLinux - cp -r $outputFolder $outputFolderLinux + rm -rf $folder + mkdir -p $folder + cp -r $outputFolder/$framework/$runtime/publish/* $folder + cp -r $outputFolder/Lidarr.Update/$framework/$runtime/publish $folder/Lidarr.Update + cp -r $outputFolder/UI $folder - echo "Creating MDBs" - CreateMdbs $outputFolderLinux + echo "Adding LICENSE" + cp LICENSE.md $folder +} - echo "Removing PDBs" - find $outputFolderLinux -name "*.pdb" -exec rm "{}" \; +PackageLinux() +{ + local framework="$1" + local runtime="$2" + + ProgressStart "Creating $runtime Package for $framework" + + local folder=$artifactsFolder/$runtime/$framework/Lidarr + + PackageFiles "$folder" "$framework" "$runtime" echo "Removing Service helpers" - rm -f $outputFolderLinux/ServiceUninstall.* - rm -f $outputFolderLinux/ServiceInstall.* - - echo "Removing native windows binaries Sqlite, MediaInfo" - rm -f $outputFolderLinux/sqlite3.* - rm -f $outputFolderLinux/MediaInfo.* - - echo "Adding Lidarr.Core.dll.config (for dllmap)" - cp $sourceFolder/NzbDrone.Core/Lidarr.Core.dll.config $outputFolderLinux - - echo "Adding CurlSharp.dll.config (for dllmap)" - cp $sourceFolder/NzbDrone.Common/CurlSharp.dll.config $outputFolderLinux - - echo "Renaming Lidarr.Console.exe to Lidarr.exe" - rm $outputFolderLinux/Lidarr.exe* - for file in $outputFolderLinux/Lidarr.Console.exe*; do - mv "$file" "${file//.Console/}" - done + rm -f $folder/ServiceUninstall.* + rm -f $folder/ServiceInstall.* echo "Removing Lidarr.Windows" - rm $outputFolderLinux/Lidarr.Windows.* + rm $folder/Lidarr.Windows.* echo "Adding Lidarr.Mono to UpdatePackage" - cp $outputFolderLinux/Lidarr.Mono.* $updateFolderMono + cp $folder/Lidarr.Mono.* $folder/Lidarr.Update + if [ "$framework" = "net6.0" ]; then + cp $folder/Mono.Posix.NETStandard.* $folder/Lidarr.Update + cp $folder/libMonoPosixHelper.* $folder/Lidarr.Update + fi - ProgressEnd 'Creating Mono Package' + ProgressEnd "Creating $runtime Package for $framework" } -PackageOsx() +PackageMacOS() { - ProgressStart 'Creating MacOS Package' + local framework="$1" + local runtime="$2" + + ProgressStart "Creating $runtime Package for $framework" - rm -rf $outputFolderMacOS - cp -r $outputFolderLinux $outputFolderMacOS + local folder=$artifactsFolder/$runtime/$framework/Lidarr - echo "Adding sqlite dylibs" - cp $sourceFolder/Libraries/Sqlite/*.dylib $outputFolderMacOS + PackageFiles "$folder" "$framework" "$runtime" - echo "Adding MediaInfo dylib" - cp $sourceFolder/Libraries/MediaInfo/*.dylib $outputFolderMacOS + echo "Removing Service helpers" + rm -f $folder/ServiceUninstall.* + rm -f $folder/ServiceInstall.* - echo "Adding Startup script" - cp ./osx/Lidarr $outputFolderMacOS + echo "Removing Lidarr.Windows" + rm $folder/Lidarr.Windows.* + + echo "Adding Lidarr.Mono to UpdatePackage" + cp $folder/Lidarr.Mono.* $folder/Lidarr.Update + if [ "$framework" = "net6.0" ]; then + cp $folder/Mono.Posix.NETStandard.* $folder/Lidarr.Update + cp $folder/libMonoPosixHelper.* $folder/Lidarr.Update + fi ProgressEnd 'Creating MacOS Package' } -PackageOsxApp() +PackageMacOSApp() { - ProgressStart 'Creating MacOS App Package' + local framework="$1" + local runtime="$2" + + ProgressStart "Creating $runtime App Package for $framework" - rm -rf $outputFolderMacOSApp - mkdir $outputFolderMacOSApp + local folder=$artifactsFolder/$runtime-app/$framework - cp -r ./osx/Lidarr.app $outputFolderMacOSApp - cp -r $outputFolderMacOS $outputFolderMacOSApp/Lidarr.app/Contents/MacOS + rm -rf $folder + mkdir -p $folder + cp -r distribution/osx/Lidarr.app $folder + mkdir -p $folder/Lidarr.app/Contents/MacOS - ProgressEnd 'Creating MacOS App Package' + echo "Copying Binaries" + cp -r $artifactsFolder/$runtime/$framework/Lidarr/* $folder/Lidarr.app/Contents/MacOS + + echo "Removing Update Folder" + rm -r $folder/Lidarr.app/Contents/MacOS/Lidarr.Update + + ProgressEnd 'Creating macOS App Package' +} + +PackageWindows() +{ + local framework="$1" + local runtime="$2" + + ProgressStart "Creating Windows Package for $framework" + + local folder=$artifactsFolder/$runtime/$framework/Lidarr + + PackageFiles "$folder" "$framework" "$runtime" + cp -r $outputFolder/$framework-windows/$runtime/publish/* $folder + + echo "Removing Lidarr.Mono" + rm -f $folder/Lidarr.Mono.* + rm -f $folder/Mono.Posix.NETStandard.* + rm -f $folder/libMonoPosixHelper.* + + echo "Adding Lidarr.Windows to UpdatePackage" + cp $folder/Lidarr.Windows.* $folder/Lidarr.Update + + ProgressEnd "Creating $runtime Package for $framework" +} + +Package() +{ + local framework="$1" + local runtime="$2" + local SPLIT + + IFS='-' read -ra SPLIT <<< "$runtime" + + case "${SPLIT[0]}" in + linux|freebsd*) + PackageLinux "$framework" "$runtime" + ;; + win) + PackageWindows "$framework" "$runtime" + ;; + osx) + PackageMacOS "$framework" "$runtime" + PackageMacOSApp "$framework" "$runtime" + ;; + esac +} + +BuildInstaller() +{ + local framework="$1" + local runtime="$2" + + ./_inno/ISCC.exe distribution/windows/setup/lidarr.iss "//DFramework=$framework" "//DRuntime=$runtime" +} + +InstallInno() +{ + ProgressStart "Installing portable Inno Setup" + + rm -rf _inno + curl -s --output innosetup.exe "https://files.jrsoftware.org/is/6/innosetup-${INNOVERSION:-6.2.0}.exe" + mkdir _inno + ./innosetup.exe //portable=1 //silent //currentuser //dir=.\\_inno + rm innosetup.exe + + ProgressEnd "Installed portable Inno Setup" +} + +RemoveInno() +{ + rm -rf _inno } PackageTests() { - ProgressStart 'Creating Test Package' + local framework="$1" + local runtime="$2" - rm -rf $testPackageFolder - mkdir $testPackageFolder + cp test.sh "$testPackageFolder/$framework/$runtime/publish" - find $sourceFolder -path $testSearchPattern -exec cp -r -u -T "{}" $testPackageFolder \; - - if [ $runtime = "dotnet" ] ; then - $nuget install NUnit.ConsoleRunner -Version 3.7.0 -Output $testPackageFolder - else - mono $nuget install NUnit.ConsoleRunner -Version 3.7.0 -Output $testPackageFolder - fi - - cp $outputFolder/*.dll $testPackageFolder - cp ./*.sh $testPackageFolder - - echo "Creating MDBs for tests" - CreateMdbs $testPackageFolder - - rm -f $testPackageFolder/*.log.config - - CleanFolder $testPackageFolder true - - echo "Adding Lidarr.Core.dll.config (for dllmap)" - cp $sourceFolder/NzbDrone.Core/Lidarr.Core.dll.config $testPackageFolder - - echo "Adding CurlSharp.dll.config (for dllmap)" - cp $sourceFolder/NzbDrone.Common/CurlSharp.dll.config $testPackageFolder - - echo "Copying CurlSharp libraries" - cp $sourceFolder/ExternalModules/CurlSharp/libs/i386/* $testPackageFolder + rm -f $testPackageFolder/$framework/$runtime/*.log.config ProgressEnd 'Creating Test Package' } -CleanupWindowsPackage() -{ - ProgressStart 'Cleaning Windows Package' - - echo "Removing Lidarr.Mono" - rm -f $outputFolder/Lidarr.Mono.* - - echo "Adding Lidarr.Windows to UpdatePackage" - cp $outputFolder/Lidarr.Windows.* $updateFolder - - ProgressEnd 'Cleaning Windows Package' -} - -PackageArtifacts() -{ - echo "Creating Artifact Directories" - - rm -rf $artifactsFolder - mkdir $artifactsFolder - - mkdir $artifactsFolderWindows - mkdir $artifactsFolderMacOS - mkdir $artifactsFolderLinux - mkdir $artifactsFolderWindows/Lidarr - mkdir $artifactsFolderMacOS/Lidarr - mkdir $artifactsFolderLinux/Lidarr - mkdir $artifactsFolderMacOSApp - - cp -r $outputFolder/* $artifactsFolderWindows/Lidarr - cp -r $outputFolderMacOSApp/* $artifactsFolderMacOSApp - cp -r $outputFolderMacOS/* $artifactsFolderMacOS/Lidarr - cp -r $outputFolderLinux/* $artifactsFolderLinux/Lidarr -} - # Use mono or .net depending on OS case "$(uname -s)" in CYGWIN*|MINGW32*|MINGW64*|MSYS*) # on windows, use dotnet - runtime="dotnet" + os="windows" ;; *) # otherwise use mono - runtime="mono" + os="posix" ;; esac -Build -RunGulp -PackageMono -PackageOsx -PackageOsxApp -PackageTests -CleanupWindowsPackage -PackageArtifacts +POSITIONAL=() + +if [ $# -eq 0 ]; then + echo "No arguments provided, building everything" + BACKEND=YES + FRONTEND=YES + PACKAGES=YES + INSTALLER=NO + LINT=YES + ENABLE_EXTRA_PLATFORMS=NO + ENABLE_EXTRA_PLATFORMS_IN_SDK=NO +fi + +while [[ $# -gt 0 ]] +do +key="$1" + +case $key in + --backend) + BACKEND=YES + shift # past argument + ;; + --enable-bsd|--enable-extra-platforms) + ENABLE_EXTRA_PLATFORMS=YES + shift # past argument + ;; + --enable-extra-platforms-in-sdk) + ENABLE_EXTRA_PLATFORMS_IN_SDK=YES + shift # past argument + ;; + -r|--runtime) + RID="$2" + shift # past argument + shift # past value + ;; + -f|--framework) + FRAMEWORK="$2" + shift # past argument + shift # past value + ;; + --frontend) + FRONTEND=YES + shift # past argument + ;; + --packages) + PACKAGES=YES + shift # past argument + ;; + --installer) + INSTALLER=YES + shift # past argument + ;; + --lint) + LINT=YES + shift # past argument + ;; + --all) + BACKEND=YES + FRONTEND=YES + PACKAGES=YES + LINT=YES + shift # past argument + ;; + *) # unknown option + POSITIONAL+=("$1") # save it in an array for later + shift # past argument + ;; +esac +done +set -- "${POSITIONAL[@]}" # restore positional parameters + +if [ "$ENABLE_EXTRA_PLATFORMS_IN_SDK" = "YES" ]; +then + EnableExtraPlatformsInSDK +fi + +if [ "$BACKEND" = "YES" ]; +then + UpdateVersionNumber + if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ]; + then + EnableExtraPlatforms + fi + Build + if [[ -z "$RID" || -z "$FRAMEWORK" ]]; + then + PackageTests "net6.0" "win-x64" + PackageTests "net6.0" "win-x86" + PackageTests "net6.0" "linux-x64" + PackageTests "net6.0" "linux-musl-x64" + PackageTests "net6.0" "osx-x64" + if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ]; + then + PackageTests "net6.0" "freebsd-x64" + PackageTests "net6.0" "linux-x86" + fi + else + PackageTests "$FRAMEWORK" "$RID" + fi +fi + +if [[ "$LINT" = "YES" || "$FRONTEND" = "YES" ]]; +then + YarnInstall +fi + +if [ "$LINT" = "YES" ]; +then + LintUI +fi + +if [ "$FRONTEND" = "YES" ]; +then + RunWebpack +fi + +if [ "$PACKAGES" = "YES" ]; +then + UpdateVersionNumber + + if [[ -z "$RID" || -z "$FRAMEWORK" ]]; + then + Package "net6.0" "win-x64" + Package "net6.0" "win-x86" + Package "net6.0" "linux-x64" + Package "net6.0" "linux-musl-x64" + Package "net6.0" "linux-arm64" + Package "net6.0" "linux-musl-arm64" + Package "net6.0" "linux-arm" + Package "net6.0" "linux-musl-arm" + Package "net6.0" "osx-x64" + Package "net6.0" "osx-arm64" + if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ]; + then + Package "net6.0" "freebsd-x64" + Package "net6.0" "linux-x86" + fi + else + Package "$FRAMEWORK" "$RID" + fi +fi + +if [ "$INSTALLER" = "YES" ]; +then + InstallInno + BuildInstaller "net6.0" "win-x64" + BuildInstaller "net6.0" "win-x86" + RemoveInno +fi diff --git a/debian/changelog b/debian/changelog deleted file mode 100644 index eb8f841a6..000000000 --- a/debian/changelog +++ /dev/null @@ -1,5 +0,0 @@ -nzbdrone {version} {branch}; urgency=low - - * Automatic Release. - - -- NzbDrone Mon, 26 Aug 2013 00:00:00 -0700 diff --git a/debian/compat b/debian/compat deleted file mode 100644 index 45a4fb75d..000000000 --- a/debian/compat +++ /dev/null @@ -1 +0,0 @@ -8 diff --git a/debian/control b/debian/control deleted file mode 100644 index 34586f51d..000000000 --- a/debian/control +++ /dev/null @@ -1,12 +0,0 @@ -Section: web -Priority: optional -Maintainer: Sonarr -Source: nzbdrone -Homepage: https://lidarr.audio -Vcs-Git: git@github.com:lidarr/Lidarr.git -Vcs-Browser: https://github.com/lidarr/Lidarr - -Package: nzbdrone -Architecture: all -Depends: libmono-cil-dev (>= 3.2), sqlite3 (>= 3.7), mediainfo (>= 0.7.52) -Description: Lidarr is a music collection manager diff --git a/debian/copyright b/debian/copyright deleted file mode 100755 index 667d82a43..000000000 --- a/debian/copyright +++ /dev/null @@ -1,24 +0,0 @@ -Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ -Upstream-Name: nzbdrone -Source: https://github.com/lidarr/Lidarr - -Files: * -Copyright: 2010-2016 Lidarr - -License: GPL-3.0+ - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - . - This package is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - . - You should have received a copy of the GNU General Public License - along with this program. If not, see . - . - On Debian systems, the complete text of the GNU General - Public License version 3 can be found in "/usr/share/common-licenses/GPL-3". diff --git a/debian/install b/debian/install deleted file mode 100755 index 106b06a9b..000000000 --- a/debian/install +++ /dev/null @@ -1 +0,0 @@ -nzbdrone_bin/* opt/NzbDrone diff --git a/debian/rules b/debian/rules deleted file mode 100755 index b760bee7f..000000000 --- a/debian/rules +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/make -f -# -*- makefile -*- -# Sample debian/rules that uses debhelper. -# This file was originally written by Joey Hess and Craig Small. -# As a special exception, when this file is copied by dh-make into a -# dh-make output file, you may use that output file without restriction. -# This special exception was added by Craig Small in version 0.37 of dh-make. - -# Uncomment this to turn on verbose mode. -#export DH_VERBOSE=1 - -%: - dh $@ diff --git a/distribution/debian/install.sh b/distribution/debian/install.sh new file mode 100644 index 000000000..b71eb20c9 --- /dev/null +++ b/distribution/debian/install.sh @@ -0,0 +1,182 @@ +#!/bin/bash +### Description: Lidarr .NET Debian install +### Originally written for Radarr by: DoctorArr - doctorarr@the-rowlands.co.uk on 2021-10-01 v1.0 +### Updates for servarr suite made by Bakerboy448, DoctorArr, brightghost, aeramor and VP-EN +### Version v1.0.0 2023-12-29 - StevieTV - adapted from servarr script for Lidarr installs +### Version V1.0.1 2024-01-02 - StevieTV - remove UTF8-BOM +### Version V1.0.2 2024-01-03 - markus101 - Get user input from /dev/tty +### Version V1.0.3 2024-01-06 - StevieTV - exit script when it is ran from install directory + +### Boilerplate Warning +#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +#EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +#MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +#NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +#LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +#OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +#WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +scriptversion="1.0.3" +scriptdate="2024-01-06" + +set -euo pipefail + +echo "Running Lidarr Install Script - Version [$scriptversion] as of [$scriptdate]" + +# Am I root?, need root! + +if [ "$EUID" -ne 0 ]; then + echo "Please run as root." + exit +fi + +app="lidarr" +app_port="8686" +app_prereq="curl sqlite3 wget" +app_umask="0002" +branch="main" + +# Constants +### Update these variables as required for your specific instance +installdir="/opt" # {Update me if needed} Install Location +bindir="${installdir}/${app^}" # Full Path to Install Location +datadir="/var/lib/$app/" # {Update me if needed} AppData directory to use +app_bin=${app^} # Binary Name of the app + +# This script should not be ran from installdir, otherwise later in the script the extracted files will be removed before they can be moved to installdir. +if [ "$installdir" == "$(dirname -- "$( readlink -f -- "$0"; )")" ] || [ "$bindir" == "$(dirname -- "$( readlink -f -- "$0"; )")" ]; then + echo "You should not run this script from the intended install directory. The script will exit. Please re-run it from another directory" + exit +fi + +# Prompt User +read -r -p "What user should ${app^} run as? (Default: $app): " app_uid < /dev/tty +app_uid=$(echo "$app_uid" | tr -d ' ') +app_uid=${app_uid:-$app} +# Prompt Group +read -r -p "What group should ${app^} run as? (Default: media): " app_guid < /dev/tty +app_guid=$(echo "$app_guid" | tr -d ' ') +app_guid=${app_guid:-media} + +echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory" +echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that the selected user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories" +read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty + +# Create User / Group as needed +if [ "$app_guid" != "$app_uid" ]; then + if ! getent group "$app_guid" >/dev/null; then + groupadd "$app_guid" + fi +fi +if ! getent passwd "$app_uid" >/dev/null; then + adduser --system --no-create-home --ingroup "$app_guid" "$app_uid" + echo "Created and added User [$app_uid] to Group [$app_guid]" +fi +if ! getent group "$app_guid" | grep -qw "$app_uid"; then + echo "User [$app_uid] did not exist in Group [$app_guid]" + usermod -a -G "$app_guid" "$app_uid" + echo "Added User [$app_uid] to Group [$app_guid]" +fi + +# Stop the App if running +if service --status-all | grep -Fq "$app"; then + systemctl stop "$app" + systemctl disable "$app".service + echo "Stopped existing $app" +fi + +# Create Appdata Directory + +# AppData +mkdir -p "$datadir" +chown -R "$app_uid":"$app_guid" "$datadir" +chmod 775 "$datadir" +echo "Directories created" +# Download and install the App + +# prerequisite packages +echo "" +echo "Installing pre-requisite Packages" +# shellcheck disable=SC2086 +apt update && apt install -y $app_prereq +echo "" +ARCH=$(dpkg --print-architecture) +# get arch +dlbase="https://lidarr.servarr.com/v1/update/$branch/updatefile?os=linux&runtime=netcore" +case "$ARCH" in +"amd64") DLURL="${dlbase}&arch=x64" ;; +"armhf") DLURL="${dlbase}&arch=arm" ;; +"arm64") DLURL="${dlbase}&arch=arm64" ;; +*) + echo "Arch not supported" + exit 1 + ;; +esac +echo "" +echo "Removing previous tarballs" +# -f to Force so we fail if it doesn't exist +rm -f "${app^}".*.tar.gz +echo "" +echo "Downloading..." +wget --content-disposition "$DLURL" +tar -xvzf "${app^}".*.tar.gz +echo "" +echo "Installation files downloaded and extracted" + +# remove existing installs +echo "Removing existing installation" +rm -rf "$bindir" +echo "Installing..." +mv "${app^}" $installdir +chown "$app_uid":"$app_guid" -R "$bindir" +chmod 775 "$bindir" +rm -rf "${app^}.*.tar.gz" +# Ensure we check for an update in case user installs older version or different branch +touch "$datadir"/update_required +chown "$app_uid":"$app_guid" "$datadir"/update_required +echo "App Installed" +# Configure Autostart + +# Remove any previous app .service +echo "Removing old service file" +rm -rf /etc/systemd/system/"$app".service + +# Create app .service with correct user startup +echo "Creating service file" +cat </dev/null +[Unit] +Description=${app^} Daemon +After=syslog.target network.target +[Service] +User=$app_uid +Group=$app_guid +UMask=$app_umask +Type=simple +ExecStart=$bindir/$app_bin -nobrowser -data=$datadir +TimeoutStopSec=20 +KillMode=process +Restart=on-failure +[Install] +WantedBy=multi-user.target +EOF + +# Start the App +echo "Service file created. Attempting to start the app" +systemctl -q daemon-reload +systemctl enable --now -q "$app" + +# Finish Update/Installation +host=$(hostname -I) +ip_local=$(grep -oP '^\S*' <<<"$host") +echo "" +echo "Install complete" +sleep 10 +STATUS="$(systemctl is-active "$app")" +if [ "${STATUS}" = "active" ]; then + echo "Browse to http://$ip_local:$app_port for the ${app^} GUI" +else + echo "${app^} failed to start" +fi + +# Exit +exit 0 diff --git a/distribution/debian/lidarr.service b/distribution/debian/lidarr.service new file mode 100644 index 000000000..8ec5b5b1d --- /dev/null +++ b/distribution/debian/lidarr.service @@ -0,0 +1,20 @@ +# This file is owned by the lidarr package, DO NOT MODIFY MANUALLY +# Instead use 'dpkg-reconfigure -plow lidarr' to modify User/Group/UMask/-data +# Or use systemd built-in override functionality using 'systemctl edit lidarr' +[Unit] +Description=Lidarr Daemon +After=network.target + +[Service] +User=lidarr +Group=lidarr +UMask=002 + +Type=simple +ExecStart=/opt/Lidarr/Lidarr -nobrowser -data=/var/lib/lidarr +TimeoutStopSec=20 +KillMode=process +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/osx/Lidarr.app/Contents/Info.plist b/distribution/osx/Lidarr.app/Contents/Info.plist similarity index 94% rename from osx/Lidarr.app/Contents/Info.plist rename to distribution/osx/Lidarr.app/Contents/Info.plist index 84932665b..6e4706fea 100644 --- a/osx/Lidarr.app/Contents/Info.plist +++ b/distribution/osx/Lidarr.app/Contents/Info.plist @@ -23,11 +23,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.0 + 10.0.0.0 CFBundleSignature xmmd CFBundleVersion - 2.0 + 10.0.0.0 NSAppleScriptEnabled YES diff --git a/osx/Lidarr.app/Contents/Resources/lidarr.icns b/distribution/osx/Lidarr.app/Contents/Resources/lidarr.icns similarity index 100% rename from osx/Lidarr.app/Contents/Resources/lidarr.icns rename to distribution/osx/Lidarr.app/Contents/Resources/lidarr.icns diff --git a/distribution/windows/setup/lidarr.iss b/distribution/windows/setup/lidarr.iss new file mode 100644 index 000000000..f5c0cf791 --- /dev/null +++ b/distribution/windows/setup/lidarr.iss @@ -0,0 +1,84 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define AppName "Lidarr" +#define AppPublisher "Team Lidarr" +#define AppURL "https://lidarr.audio/" +#define ForumsURL "https://lidarr.audio/discord" +#define AppExeName "Lidarr.exe" +#define BaseVersion GetEnv('MAJORVERSION') +#define BuildNumber GetEnv('MINORVERSION') +#define BuildVersion GetEnv('LIDARRVERSION') + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. +; Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{56C1065D-3523-4025-B76D-6F73F67F7F93} +AppName={#AppName} +AppVersion={#BaseVersion} +AppPublisher={#AppPublisher} +AppPublisherURL={#AppURL} +AppSupportURL={#ForumsURL} +AppUpdatesURL={#AppURL} +DefaultDirName={commonappdata}\Lidarr +DisableDirPage=yes +DefaultGroupName={#AppName} +DisableProgramGroupPage=yes +OutputBaseFilename=Lidarr.{#BuildVersion}.{#Runtime} +SolidCompression=yes +AppCopyright=Creative Commons 3.0 License +AllowUNCPath=False +UninstallDisplayIcon={app}\bin\Lidarr.exe +DisableReadyPage=True +CompressionThreads=2 +Compression=lzma2/normal +AppContact={#ForumsURL} +VersionInfoVersion={#BaseVersion}.{#BuildNumber} +SetupLogging=yes +OutputDir=output +WizardStyle=modern + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopIcon"; Description: "{cm:CreateDesktopIcon}" +Name: "windowsService"; Description: "Install Windows Service (Starts when the computer starts as the LocalService user, you will need to change the user to access network shares)"; GroupDescription: "Start automatically"; Flags: exclusive +Name: "startupShortcut"; Description: "Create shortcut in Startup folder (Starts when you log into Windows)"; GroupDescription: "Start automatically"; Flags: exclusive unchecked +Name: "none"; Description: "Do not start automatically"; GroupDescription: "Start automatically"; Flags: exclusive unchecked + +[Dirs] +Name: "{app}"; Permissions: users-modify + +[Files] +Source: "..\..\..\_artifacts\{#Runtime}\{#Framework}\Lidarr\Lidarr.exe"; DestDir: "{app}\bin"; Flags: ignoreversion +Source: "..\..\..\_artifacts\{#Runtime}\{#Framework}\Lidarr\*"; Excludes: "Lidarr.Update"; DestDir: "{app}\bin"; Flags: ignoreversion recursesubdirs createallsubdirs +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{group}\{#AppName}"; Filename: "{app}\bin\{#AppExeName}"; Parameters: "/icon" +Name: "{commondesktop}\{#AppName}"; Filename: "{app}\bin\{#AppExeName}"; Parameters: "/icon"; Tasks: desktopIcon +Name: "{userstartup}\{#AppName}"; Filename: "{app}\bin\Lidarr.exe"; WorkingDir: "{app}\bin"; Tasks: startupShortcut + +[InstallDelete] +Name: "{app}\bin"; Type: filesandordirs + +[Run] +Filename: "{app}\bin\Lidarr.Console.exe"; StatusMsg: "Removing previous Windows Service"; Parameters: "/u /exitimmediately"; Flags: runhidden waituntilterminated; +Filename: "{app}\bin\Lidarr.Console.exe"; Description: "Enable Access from Other Devices"; StatusMsg: "Enabling Remote access"; Parameters: "/registerurl /exitimmediately"; Flags: postinstall runascurrentuser runhidden waituntilterminated; Tasks: startupShortcut none; +Filename: "{app}\bin\Lidarr.Console.exe"; StatusMsg: "Installing Windows Service"; Parameters: "/i /exitimmediately"; Flags: runhidden waituntilterminated; Tasks: windowsService +Filename: "{app}\bin\Lidarr.exe"; Description: "Open Lidarr Web UI"; Flags: postinstall skipifsilent nowait; Tasks: windowsService; +Filename: "{app}\bin\Lidarr.exe"; Description: "Start Lidarr"; Flags: postinstall skipifsilent nowait; Tasks: startupShortcut none; + +[UninstallRun] +Filename: "{app}\bin\lidarr.console.exe"; Parameters: "/u"; Flags: waituntilterminated skipifdoesntexist + +[Code] +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + ResultCode: Integer; +begin + Exec('net', 'stop lidarr', '', 0, ewWaitUntilTerminated, ResultCode) + Exec('sc', 'delete lidarr', '', 0, ewWaitUntilTerminated, ResultCode) +end; diff --git a/docs.sh b/docs.sh new file mode 100644 index 000000000..a44dc90ce --- /dev/null +++ b/docs.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -e + +FRAMEWORK="net6.0" +PLATFORM=$1 +ARCHITECTURE="${2:-x64}" + +if [ "$PLATFORM" = "Windows" ]; then + RUNTIME="win-$ARCHITECTURE" +elif [ "$PLATFORM" = "Linux" ]; then + RUNTIME="linux-$ARCHITECTURE" +elif [ "$PLATFORM" = "Mac" ]; then + RUNTIME="osx-$ARCHITECTURE" +else + echo "Platform must be provided as first argument: Windows, Linux or Mac" + exit 1 +fi + +outputFolder='_output' +testPackageFolder='_tests' + +rm -rf $outputFolder +rm -rf $testPackageFolder + +slnFile=src/Lidarr.sln + +platform=Posix + +if [ "$PLATFORM" = "Windows" ]; then + application=Lidarr.Console.dll +else + application=Lidarr.dll +fi + +dotnet clean $slnFile -c Debug +dotnet clean $slnFile -c Release + +dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids + +dotnet new tool-manifest +dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli + +dotnet tool run swagger tofile --output ./src/Lidarr.Api.V1/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v1 & + +sleep 45 + +kill %1 + +exit 0 diff --git a/frontend/.csscomb.json b/frontend/.csscomb.json deleted file mode 100644 index a82e49732..000000000 --- a/frontend/.csscomb.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "remove-empty-rulesets": true, - "always-semicolon": true, - "color-case": "lower", - "block-indent": " ", - "color-shorthand": false, - "element-case": "lower", - "eof-newline": true, - "leading-zero": true, - "quotes": "double", - "sort-order-fallback": "abc", - "space-before-colon": "", - "space-after-colon": " ", - "space-before-combinator": " ", - "space-after-combinator": " ", - "space-between-declarations": "\n", - "space-before-opening-brace": " ", - "space-after-opening-brace": "\n", - "space-after-selector-delimiter": " ", - "space-before-selector-delimiter": "", - "space-before-closing-brace": "\n", - "strip-spaces": true, - "tab-size": true, - "unitless-zero": false -} diff --git a/frontend/.editorconfig b/frontend/.editorconfig deleted file mode 100644 index c14ef65ef..000000000 --- a/frontend/.editorconfig +++ /dev/null @@ -1,6 +0,0 @@ -[*] -insert_final_newline = true - -[*.{js,css}] -indent_style = space -indent_size = 2 diff --git a/frontend/.esformatter b/frontend/.esformatter deleted file mode 100644 index 600bb0751..000000000 --- a/frontend/.esformatter +++ /dev/null @@ -1,335 +0,0 @@ -{ - "indent": { - "value": " ", - "FunctionExpression": 1, - "ArrayExpression": 1, - "ObjectExpression": 1 - }, - "lineBreak": { - "value": "\n", - - "before": { - "ArrayPatternClosing": 0, - "ArrayPatternComma": 0, - "ArrayPatternOpening": 0, - "ArrowFunctionExpressionArrow": 0, - "ArrowFunctionExpressionClosingBrace": ">=1", - "ArrowFunctionExpressionOpeningBrace": 0, - "AssignmentExpression": ">=1", - "AssignmentOperator": 0, - "BlockStatement": 0, - "BreakKeyword": ">=1", - "CallExpression": -1, - "CallExpressionClosingParentheses": -1, - "CallExpressionOpeningParentheses": 0, - "CatchClosingBrace": ">=1", - "CatchKeyword": 0, - "CatchOpeningBrace": 0, - "ClassDeclaration": ">=1", - "ClassDeclarationClosingBrace": ">=1", - "ClassDeclarationOpeningBrace": 0, - "ConditionalExpression": ">=1", - "DeleteOperator": ">=1", - "DoWhileStatement": ">=1", - "DoWhileStatementClosingBrace": ">=1", - "DoWhileStatementOpeningBrace": 0, - "ElseIfStatement": 0, - "ElseIfStatementClosingBrace": ">=1", - "ElseIfStatementOpeningBrace": 0, - "ElseStatement": 0, - "ElseStatementClosingBrace": ">=1", - "ElseStatementOpeningBrace": 0, - "EmptyStatement": -1, - "EndOfFile": -1, - "FinallyClosingBrace": ">=1", - "FinallyKeyword": -1, - "FinallyOpeningBrace": 0, - "ForInStatement": ">=1", - "ForInStatementClosingBrace": ">=1", - "ForInStatementExpressionClosing": 0, - "ForInStatementExpressionOpening": 0, - "ForInStatementOpeningBrace": 0, - "ForStatement": ">=1", - "ForStatementClosingBrace": ">=1", - "ForStatementExpressionClosing": "<2", - "ForStatementExpressionOpening": 0, - "ForStatementOpeningBrace": 0, - "FunctionDeclaration": ">=1", - "FunctionDeclarationClosingBrace": ">=1", - "FunctionDeclarationOpeningBrace": 0, - "FunctionExpression": 0, - "FunctionExpressionClosingBrace": 1, - "FunctionExpressionOpeningBrace":0, - "IIFEClosingParentheses": 0, - "IfStatement": ">=1", - "IfStatementClosingBrace": ">=1", - "IfStatementOpeningBrace": 0, - "LogicalExpression": -1, - "MemberExpressionClosing": 0, - "MemberExpressionOpening": 0, - "MemberExpressionPeriod": -1, - "MethodDefinition": ">=1", - "ObjectExpressionClosingBrace": "<=1", - "ObjectPatternClosingBrace": 0, - "ObjectPatternComma": 0, - "ObjectPatternOpeningBrace": 0, - "ParameterDefault": 0, - "Property": "<=2", - "PropertyValue": 0, - "ReturnStatement": -1, - "SwitchClosingBrace": ">=1", - "SwitchOpeningBrace": 0, - "ThisExpression": -1, - "ThrowStatement": ">=1", - "TryClosingBrace": ">=1", - "TryKeyword": -1, - "TryOpeningBrace": 0, - "VariableDeclaration": ">=1", - "VariableDeclarationSemiColon": 0, - "VariableDeclarationWithoutInit": ">=1", - "VariableName": ">=1", - "VariableValue": 0, - "WhileStatement": ">=1", - "WhileStatementClosingBrace": ">=1", - "WhileStatementOpeningBrace": 0 - }, - - "after": { - "ArrayPatternClosing": 0, - "ArrayPatternComma": 0, - "ArrayPatternOpening": 0, - "ArrowFunctionExpressionArrow": 0, - "ArrowFunctionExpressionClosingBrace": -1, - "ArrowFunctionExpressionOpeningBrace": ">=1", - "AssignmentExpression": ">=1", - "AssignmentOperator": 0, - "BlockStatement": 0, - "BreakKeyword": -1, - "CallExpression": -1, - "CallExpressionClosingParentheses": -1, - "CallExpressionOpeningParentheses": -1, - "CatchClosingBrace": ">=0", - "CatchKeyword": 0, - "CatchOpeningBrace": ">=1", - "ClassDeclaration": ">=1", - "ClassDeclarationClosingBrace": ">=1", - "ClassDeclarationOpeningBrace": ">=1", - "ConditionalExpression": ">=1", - "DeleteOperator": ">=1", - "DoWhileStatement": ">=1", - "DoWhileStatementClosingBrace": 0, - "DoWhileStatementOpeningBrace": ">=1", - "ElseIfStatement": ">=1", - "ElseIfStatementClosingBrace": ">=1", - "ElseIfStatementOpeningBrace": ">=1", - "ElseStatement": ">=1", - "ElseStatementClosingBrace": ">=1", - "ElseStatementOpeningBrace": ">=1", - "EmptyStatement": -1, - "FinallyClosingBrace": ">=1", - "FinallyKeyword": -1, - "FinallyOpeningBrace": ">=1", - "ForInStatement": ">=1", - "ForInStatementClosingBrace": ">=1", - "ForInStatementExpressionClosing": -1, - "ForInStatementExpressionOpening": "<2", - "ForInStatementOpeningBrace": ">=1", - "ForStatement": ">=1", - "ForStatementClosingBrace": ">=1", - "ForStatementExpressionClosing": -1, - "ForStatementExpressionOpening": "<2", - "ForStatementOpeningBrace": ">=1", - "FunctionDeclaration": ">=1", - "FunctionDeclarationClosingBrace": ">=1", - "FunctionDeclarationOpeningBrace": ">=1", - "FunctionExpression": 0, - "FunctionExpressionClosingBrace": -1, - "FunctionExpressionOpeningBrace": 1, - "IIFEOpeningParentheses": 0, - "IfStatement": ">=1", - "IfStatementClosingBrace": ">=1", - "IfStatementOpeningBrace": ">=1", - "LogicalExpression": -1, - "MemberExpressionClosing": 0, - "MemberExpressionOpening": 0, - "MemberExpressionPeriod": 0, - "MethodDefinition": ">=1", - "ObjectExpressionOpeningBrace": "<=1", - "ObjectPatternClosingBrace": 0, - "ObjectPatternComma": 0, - "ObjectPatternOpeningBrace": 0, - "ParameterDefault": 0, - "Property": -1, - "PropertyName": 0, - "ReturnStatement": -1, - "SwitchCaseColon": ">=1", - "SwitchClosingBrace": ">=1", - "SwitchOpeningBrace": ">=1", - "ThisExpression": 0, - "ThrowStatement": ">=1", - "TryClosingBrace": 0, - "TryKeyword": -1, - "TryOpeningBrace": ">=1", - "VariableDeclaration": ">=1", - "VariableDeclarationSemiColon": ">=1", - "VariableValue": -1, - "WhileStatement": ">=1", - "WhileStatementClosingBrace": ">=1", - "WhileStatementOpeningBrace": ">=1" - } - }, - "whiteSpace": { - "value": " ", - "removeTrailing": 1, - "before": { - "ArgumentComma": 0, - "ArgumentList": 0, - "ArgumentListArrayExpression": 0, - "ArgumentListFunctionExpression": 1, - "ArgumentListObjectExpression": 0, - "ArrayExpressionClosing": 0, - "ArrayExpressionComma": 0, - "ArrayExpressionOpening": 1, - "AssignmentOperator": 1, - "BinaryExpression": 0, - "BinaryExpressionOperator": 1, - "BlockComment": 1, - "CallExpression": 1, - "CatchClosingBrace": 1, - "CatchKeyword": 1, - "CatchOpeningBrace": 1, - "CatchParameterList": 0, - "CommaOperator": 0, - "ConditionalExpressionAlternate": 1, - "ConditionalExpressionConsequent": 1, - "DoWhileStatementClosingBrace": 1, - "DoWhileStatementConditional": 1, - "DoWhileStatementOpeningBrace": 1, - "ElseIfStatementClosingBrace": 1, - "ElseIfStatementOpeningBrace": 1, - "ElseStatementClosingBrace": 1, - "ElseStatementOpeningBrace": 1, - "EmptyStatement": 0, - "ExpressionClosingParentheses": 0, - "FinallyClosingBrace": 1, - "FinallyKeyword": -1, - "FinallyOpeningBrace": 1, - "ForInStatement": 1, - "ForInStatementClosingBrace": 1, - "ForInStatementExpressionClosing": 0, - "ForInStatementExpressionOpening": 1, - "ForInStatementOpeningBrace": 1, - "ForStatement": 1, - "ForStatementClosingBrace": 1, - "ForStatementExpressionClosing": 0, - "ForStatementExpressionOpening": 1, - "ForStatementOpeningBrace": 1, - "ForStatementSemicolon": 0, - "FunctionDeclarationClosingBrace": 1, - "FunctionDeclarationOpeningBrace": 1, - "FunctionExpressionClosingBrace": 1, - "FunctionExpressionOpeningBrace": 1, - "IfStatementClosingBrace": 1, - "IfStatementConditionalClosing": 0, - "IfStatementConditionalOpening": 1, - "IfStatementOpeningBrace": 1, - "LineComment": 1, - "LogicalExpressionOperator": 1, - "MemberExpressionClosing": 0, - "ObjectExpressionClosingBrace": 1, - "ParameterComma": 0, - "ParameterList": 0, - "Property": 1, - "PropertyName": 1, - "PropertyValue": 1, - "SwitchDiscriminantClosing": 0, - "SwitchDiscriminantOpening": 1, - "ThrowKeyword": 1, - "TryClosingBrace": 1, - "TryKeyword": -1, - "TryOpeningBrace": 1, - "UnaryExpressionOperator": 0, - "VariableName": 1, - "VariableValue": 1, - "WhileStatementClosingBrace": 1, - "WhileStatementConditionalClosing": 0, - "WhileStatementConditionalOpening": 1, - "WhileStatementOpeningBrace": 1 - }, - "after": { - "ArgumentComma": 1, - "ArgumentList": 0, - "ArgumentListArrayExpression": 1, - "ArgumentListFunctionExpression": 1, - "ArgumentListObjectExpression": 0, - "ArrayExpressionClosing": 0, - "ArrayExpressionComma": 1, - "ArrayExpressionOpening": 0, - "AssignmentOperator": 1, - "BinaryExpression": 0, - "BinaryExpressionOperator": 1, - "BlockComment": 1, - "CallExpression": 0, - "CatchClosingBrace": 1, - "CatchKeyword": 1, - "CatchOpeningBrace": 1, - "CatchParameterList": 0, - "CommaOperator": 1, - "ConditionalExpressionConsequent": 1, - "ConditionalExpressionTest": 1, - "DoWhileStatementBody": 1, - "DoWhileStatementClosingBrace": 1, - "DoWhileStatementOpeningBrace": 1, - "ElseIfStatementClosingBrace": 1, - "ElseIfStatementOpeningBrace": 1, - "ElseStatementClosingBrace": 1, - "ElseStatementOpeningBrace": 1, - "EmptyStatement": 0, - "ExpressionOpeningParentheses": 0, - "FinallyClosingBrace": 1, - "FinallyKeyword": -1, - "FinallyOpeningBrace": 1, - "ForInStatement": 1, - "ForInStatementClosingBrace": 1, - "ForInStatementExpressionClosing": 1, - "ForInStatementExpressionOpening": 0, - "ForInStatementOpeningBrace": 1, - "ForStatement": 1, - "ForStatementClosingBrace": 1, - "ForStatementExpressionClosing": 1, - "ForStatementExpressionOpening": 0, - "ForStatementOpeningBrace": 1, - "ForStatementSemicolon": 1, - "FunctionDeclarationClosingBrace": 0, - "FunctionDeclarationOpeningBrace": 0, - "FunctionExpressionClosingBrace": 0, - "FunctionExpressionOpeningBrace": 0, - "FunctionName": 0, - "FunctionReservedWord": 0, - "IfStatementClosingBrace": 1, - "IfStatementConditionalClosing": 0, - "IfStatementConditionalOpening": 0, - "IfStatementOpeningBrace": 1, - "LogicalExpressionOperator": 1, - "MemberExpressionOpening": 0, - "ObjectExpressionClosingBrace": 0, - "ObjectExpressionOpeningBrace": 1, - "ParameterComma": 1, - "ParameterList": 0, - "PropertyName": 0, - "PropertyValue": 0, - "SwitchDiscriminantClosing": 1, - "SwitchDiscriminantOpening": 0, - "ThrowKeyword": 1, - "TryClosingBrace": 1, - "TryKeyword": -1, - "TryOpeningBrace": 1, - "UnaryExpressionOperator": 0, - "VariableName": 1, - "WhileStatementClosingBrace": 1, - "WhileStatementConditionalClosing": 1, - "WhileStatementConditionalOpening": 0, - "WhileStatementOpeningBrace": 1 - } - } -} diff --git a/frontend/.eslintignore b/frontend/.eslintignore index d4b43f836..e6d49ec4d 100644 --- a/frontend/.eslintignore +++ b/frontend/.eslintignore @@ -1 +1,2 @@ **/JsLibraries/** +**/*.css.d.ts diff --git a/frontend/.eslintrc b/frontend/.eslintrc deleted file mode 100644 index 466bb0e8c..000000000 --- a/frontend/.eslintrc +++ /dev/null @@ -1,288 +0,0 @@ -{ - "parser": "babel-eslint", - - "env": { - "browser": true, - "commonjs": true, - "node": true, - "es6": true - }, - - "globals": { - "expect": false, - "chai": false, - "sinon": false - }, - - "parserOptions": { - "ecmaVersion": 6, - "sourceType": "module", - "ecmaFeatures": { - "modules": true, - "impliedStrict": true - } - }, - - "plugins": [ - "filenames", - "react" - ], - - "rules": { - "filenames/match-exported": ["error"], - - # ECMAScript 6 - - "arrow-body-style": [0], - "arrow-parens": ["error", "always"], - "arrow-spacing": ["error", { "before": true, "after": true }], - "constructor-super": "error", - "generator-star-spacing": "off", - "no-class-assign": "error", - "no-confusing-arrow": "error", - "no-const-assign": "error", - "no-dupe-class-members": "error", - "no-duplicate-imports": "error", - "no-new-symbol": "error", - "no-this-before-super": "error", - "no-useless-escape": "error", - "no-useless-computed-key": "error", - "no-useless-constructor": "error", - "no-var": "warn", - "object-shorthand": ["error", "properties"], - "prefer-arrow-callback": "error", - "prefer-const": "warn", - "prefer-reflect": "off", - "prefer-rest-params": "off", - "prefer-spread": "warn", - "prefer-template": "error", - "require-yield": "off", - "template-curly-spacing": ["error", "never"], - "yield-star-spacing": "off", - - # Possible Errors - - "comma-dangle": "error", - "no-cond-assign": "error", - "no-console": "off", - "no-constant-condition": "warn", - "no-control-regex": "error", - "no-debugger": "off", - "no-dupe-args": "error", - "no-dupe-keys": "error", - "no-duplicate-case": "error", - "no-empty": "warn", - "no-empty-character-class": "error", - "no-ex-assign": "error", - "no-extra-boolean-cast": "error", - "no-extra-parens": ["error", "functions"], - "no-extra-semi": "error", - "no-func-assign": "error", - "no-inner-declarations": "error", - "no-invalid-regexp": "error", - "no-irregular-whitespace": "error", - "no-negated-in-lhs": "error", - "no-obj-calls": "error", - "no-regex-spaces": "error", - "no-sparse-arrays": "error", - "no-unexpected-multiline": "error", - "no-unreachable": "warn", - "no-unsafe-finally": "error", - "use-isnan": "error", - "valid-jsdoc": "off", - "valid-typeof": "error", - - # Best Practices - - "accessor-pairs": "off", - "array-callback-return": "warn", - "block-scoped-var": "warn", - "consistent-return": "off", - "curly": "error", - "default-case": "error", - "dot-location": ["error", "property"], - "dot-notation": "error", - "eqeqeq": ["error", "smart"], - "guard-for-in": "error", - "no-alert": "warn", - "no-caller": "error", - "no-case-declarations": "error", - "no-div-regex": "error", - "no-else-return": "error", - "no-empty-function": ["error", {"allow": ["arrowFunctions"]}], - "no-empty-pattern": "error", - "no-eval": "error", - "no-extend-native": "error", - "no-extra-bind": "error", - "no-fallthrough": "error", - "no-floating-decimal": "error", - "no-implicit-coercion": ["error", { - "boolean": false, - "number": true, - "string": true, - "allow": [/* "!!", "~", "*", "+" */] - }], - "no-implicit-globals": "error", - "no-implied-eval": "error", - "no-invalid-this": "off", - "no-iterator": "error", - "no-labels": "error", - "no-lone-blocks": "error", - "no-loop-func": "error", - "no-magic-numbers": ["off", {"ignoreArrayIndexes": true, "ignore": [0, 1] }], - "no-multi-spaces": "error", - "no-multi-str": "error", - "no-native-reassign": ["error", {"exceptions": ["console"]}], - "no-new": "off", - "no-new-func": "error", - "no-new-wrappers": "error", - "no-octal": "error", - "no-octal-escape": "error", - "no-param-reassign": "off", - "no-process-env": "off", - "no-proto": "error", - "no-redeclare": "error", - "no-return-assign": "warn", - "no-script-url": "error", - "no-self-assign": "error", - "no-self-compare": "error", - "no-sequences": "error", - "no-throw-literal": "error", - "no-unmodified-loop-condition": "error", - "no-unused-expressions": "error", - "no-unused-labels": "error", - "no-useless-call": "error", - "no-useless-concat": "error", - "no-void": "error", - "no-warning-comments": "off", - "no-with": "error", - "radix": ["error", "as-needed"], - "vars-on-top": "off", - "wrap-iife": ["error", "inside"], - "yoda": "error", - - # Strict Mode - - "strict": ["error", "never"], - - # Variables - - "init-declarations": ["error", "always"], - "no-catch-shadow": "error", - "no-delete-var": "error", - "no-label-var": "error", - "no-restricted-globals": "off", - "no-shadow": "error", - "no-shadow-restricted-names": "error", - "no-undef": "error", - "no-undef-init": "off", - "no-undefined": "off", - "no-unused-vars": ["warn", { "args": "none" }], - "no-use-before-define": "error", - - # Node.js and CommonJS - - "callback-return": "warn", - "global-require": "error", - "handle-callback-err": "warn", - "no-mixed-requires": "error", - "no-new-require": "error", - "no-path-concat": "error", - "no-process-exit": "error", - - # Stylistic Issues - - "array-bracket-spacing": ["error", "never"], - "block-spacing": ["error", "always"], - "brace-style": ["error", "1tbs", { "allowSingleLine": false }], - "camelcase": "off", - "comma-spacing": ["error", {"before": false, "after": true}], - "comma-style": ["error", "last"], - "computed-property-spacing": ["error", "never"], - "consistent-this": ["error", "self"], - "eol-last": "error", - "func-names": "off", - "func-style": ["error", "declaration"], - "indent": ["error", 2, {"SwitchCase": 1}], - "key-spacing": ["error", {"beforeColon": false, "afterColon": true}], - "keyword-spacing": ["error", {before: true, after: true}], - "lines-around-comment": ["error", { "beforeBlockComment": true, "afterBlockComment": false }], - "max-depth": ["error", {"maximum": 5}], - "max-nested-callbacks": ["error", 4], - "max-params": ["warn", 4], - "max-statements": "off", - "max-statements-per-line": ["error", { "max": 1 }], - "new-cap": ["error", {"capIsNewExceptions": ["$.Deferred"]}], - "new-parens": "error", - "newline-after-var": "off", - "newline-before-return": "off", - "newline-per-chained-call": "off", - "no-array-constructor": "error", - "no-bitwise": "error", - "no-continue": "error", - "no-inline-comments": "off", - "no-lonely-if": "warn", - "no-mixed-spaces-and-tabs": "error", - "no-multiple-empty-lines": ["error", {max: 1}], - "no-negated-condition": "warn", - "no-nested-ternary": "error", - "no-new-object": "error", - "no-plusplus": "off", - "no-restricted-syntax": "off", - "no-spaced-func": "error", - "no-ternary": "off", - "no-trailing-spaces": "error", - "no-underscore-dangle": ["error", { "allowAfterThis": true }], - "no-unneeded-ternary": "error", - "no-whitespace-before-property": "error", - "object-curly-spacing": ["error", "always"], - "one-var": ["error", "never"], - "one-var-declaration-per-line": ["error", "always"], - "operator-assignment": ["off", "never"], - "operator-linebreak": ["error", "after"], - "quote-props": ["error", "as-needed"], - "quotes": ["error", "single"], - "require-jsdoc": "off", - "semi": "error", - "semi-spacing": ["error", { "before": false, "after": true }], - "sort-vars": "off", - "space-before-blocks": ["error", "always"], - "space-before-function-paren": ["error", "never"], - "space-in-parens": "off", - "space-infix-ops": "off", - "space-unary-ops": "off", - "spaced-comment": "error", - "wrap-regex": "error", - - # React - - "react/jsx-boolean-value": [2, "always"], - "react/jsx-uses-vars": 2, - "react/jsx-closing-bracket-location": 2, - "react/jsx-tag-spacing": ["error"], - "react/jsx-curly-spacing": [2, "never"], - "react/jsx-equals-spacing": [2, "never"], - "react/jsx-indent-props": [2, 2], - "react/jsx-indent": [2, 2], - "react/jsx-key": 2, - "react/jsx-no-bind": [2, { "allowArrowFunctions": true }], - "react/jsx-no-duplicate-props": [2, { "ignoreCase": true }], - "react/jsx-max-props-per-line": [2, { "maximum": 2 }], - "react/jsx-handler-names": [2, { "eventHandlerPrefix": "on", "eventHandlerPropPrefix": "on" }], - "react/jsx-no-undef": 2, - "react/jsx-pascal-case": 2, - "react/jsx-uses-react": 2, - // Explicitly disabled in case we want to enable them again - "react/no-did-mount-set-state": 0, - "react/no-did-update-set-state": 0, - "react/no-direct-mutation-state": 2, - "react/no-multi-comp": [2, { "ignoreStateless": true }], - "react/no-unknown-property": 2, - "react/prefer-es6-class": 2, - "react/prop-types": 2, - "react/react-in-jsx-scope": 2, - "react/self-closing-comp": 2, - "react/sort-comp": 2, - "react/jsx-wrap-multilines": 2 - } -} diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js new file mode 100644 index 000000000..cc26a2633 --- /dev/null +++ b/frontend/.eslintrc.js @@ -0,0 +1,393 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const fs = require('fs'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const path = require('path'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const typescriptEslintRecommended = require('@typescript-eslint/eslint-plugin').configs.recommended; + +const frontendFolder = __dirname; + +const dirs = fs + .readdirSync(path.join(frontendFolder, 'src'), { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name) + .join('|'); + +module.exports = { + root: true, + + parser: '@babel/eslint-parser', + + env: { + browser: true, + commonjs: true, + node: true, + es6: true + }, + + globals: { + expect: false, + chai: false, + sinon: false, + JSX: true + }, + + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + babelOptions: { + configFile: `${frontendFolder}/babel.config.js` + }, + ecmaFeatures: { + modules: true, + impliedStrict: true + } + }, + + plugins: [ + 'filenames', + 'react', + 'react-hooks', + 'simple-import-sort', + 'import', + '@typescript-eslint', + 'prettier' + ], + + settings: { + react: { + version: 'detect' + } + }, + + rules: { + 'filenames/match-exported': ['error'], + + // ECMAScript 6 + + 'arrow-body-style': [0], + 'arrow-parens': ['error', 'always'], + 'arrow-spacing': ['error', { before: true, after: true }], + 'constructor-super': 'error', + 'generator-star-spacing': 'off', + 'no-class-assign': 'error', + 'no-confusing-arrow': 'error', + 'no-const-assign': 'error', + 'no-dupe-class-members': 'error', + 'no-duplicate-imports': 'error', + 'no-new-symbol': 'error', + 'no-this-before-super': 'error', + 'no-useless-escape': 'error', + 'no-useless-computed-key': 'error', + 'no-useless-constructor': 'error', + 'no-var': 'warn', + 'object-shorthand': ['error', 'properties'], + 'prefer-arrow-callback': 'error', + 'prefer-const': 'warn', + 'prefer-reflect': 'off', + 'prefer-rest-params': 'off', + 'prefer-spread': 'warn', + 'prefer-template': 'error', + 'require-yield': 'off', + 'template-curly-spacing': ['error', 'never'], + 'yield-star-spacing': 'off', + + // Possible Errors + + 'comma-dangle': 'error', + 'no-cond-assign': 'error', + 'no-console': 'off', + 'no-constant-condition': 'warn', + 'no-control-regex': 'error', + 'no-debugger': 'off', + 'no-dupe-args': 'error', + 'no-dupe-keys': 'error', + 'no-duplicate-case': 'error', + 'no-empty': 'warn', + 'no-empty-character-class': 'error', + 'no-ex-assign': 'error', + 'no-extra-boolean-cast': 'error', + 'no-extra-parens': ['error', 'functions'], + 'no-extra-semi': 'error', + 'no-func-assign': 'error', + 'no-inner-declarations': 'error', + 'no-invalid-regexp': 'error', + 'no-irregular-whitespace': 'error', + 'no-negated-in-lhs': 'error', + 'no-obj-calls': 'error', + 'no-regex-spaces': 'error', + 'no-sparse-arrays': 'error', + 'no-unexpected-multiline': 'error', + 'no-unreachable': 'warn', + 'no-unsafe-finally': 'error', + 'use-isnan': 'error', + 'valid-jsdoc': 'off', + 'valid-typeof': 'error', + + // Best Practices + + 'accessor-pairs': 'off', + 'array-callback-return': 'warn', + 'block-scoped-var': 'warn', + 'consistent-return': 'off', + curly: 'error', + 'default-case': 'error', + 'dot-location': ['error', 'property'], + 'dot-notation': 'error', + eqeqeq: ['error', 'smart'], + 'guard-for-in': 'error', + 'no-alert': 'warn', + 'no-caller': 'error', + 'no-case-declarations': 'error', + 'no-div-regex': 'error', + 'no-else-return': 'error', + 'no-empty-function': ['error', { allow: ['arrowFunctions'] }], + 'no-empty-pattern': 'error', + 'no-eval': 'error', + 'no-extend-native': 'error', + 'no-extra-bind': 'error', + 'no-fallthrough': 'error', + 'no-floating-decimal': 'error', + 'no-implicit-coercion': ['error', { + boolean: false, + number: true, + string: true, + allow: [/* "!!", "~", "*", "+" */] + }], + 'no-implicit-globals': 'error', + 'no-implied-eval': 'error', + 'no-invalid-this': 'off', + 'no-iterator': 'error', + 'no-labels': 'error', + 'no-lone-blocks': 'error', + 'no-loop-func': 'error', + 'no-magic-numbers': ['off', { ignoreArrayIndexes: true, ignore: [0, 1] }], + 'no-multi-spaces': 'error', + 'no-multi-str': 'error', + 'no-native-reassign': ['error', { exceptions: ['console'] }], + 'no-new': 'off', + 'no-new-func': 'error', + 'no-new-wrappers': 'error', + 'no-octal': 'error', + 'no-octal-escape': 'error', + 'no-param-reassign': 'off', + 'no-process-env': 'off', + 'no-proto': 'error', + 'no-redeclare': 'error', + 'no-return-assign': 'warn', + 'no-script-url': 'error', + 'no-self-assign': 'error', + 'no-self-compare': 'error', + 'no-sequences': 'error', + 'no-throw-literal': 'error', + 'no-unmodified-loop-condition': 'error', + 'no-unused-expressions': 'error', + 'no-unused-labels': 'error', + 'no-useless-call': 'error', + 'no-useless-concat': 'error', + 'no-void': 'error', + 'no-warning-comments': 'off', + 'no-with': 'error', + radix: ['error', 'as-needed'], + 'vars-on-top': 'off', + 'wrap-iife': ['error', 'inside'], + yoda: 'error', + + // Strict Mode + + strict: ['error', 'never'], + + // Variables + + 'init-declarations': ['error', 'always'], + 'no-catch-shadow': 'error', + 'no-delete-var': 'error', + 'no-label-var': 'error', + 'no-restricted-globals': 'off', + 'no-shadow': 'error', + 'no-shadow-restricted-names': 'error', + 'no-undef': 'error', + 'no-undef-init': 'off', + 'no-undefined': 'off', + 'no-unused-vars': ['error', { args: 'none', ignoreRestSiblings: true }], + 'no-use-before-define': 'error', + + // Node.js and CommonJS + + 'callback-return': 'warn', + 'global-require': 'error', + 'handle-callback-err': 'warn', + 'no-mixed-requires': 'error', + 'no-new-require': 'error', + 'no-path-concat': 'error', + 'no-process-exit': 'error', + + // Stylistic Issues + + 'array-bracket-spacing': ['error', 'never'], + 'block-spacing': ['error', 'always'], + 'brace-style': ['error', '1tbs', { allowSingleLine: false }], + camelcase: 'off', + 'comma-spacing': ['error', { before: false, after: true }], + 'comma-style': ['error', 'last'], + 'computed-property-spacing': ['error', 'never'], + 'consistent-this': ['error', 'self'], + 'eol-last': 'error', + 'func-names': 'off', + 'func-style': ['error', 'declaration', { allowArrowFunctions: true }], + indent: ['error', 2, { SwitchCase: 1 }], + 'key-spacing': ['error', { beforeColon: false, afterColon: true }], + 'keyword-spacing': ['error', { before: true, after: true }], + 'lines-around-comment': ['error', { beforeBlockComment: true, afterBlockComment: false }], + 'max-depth': ['error', { maximum: 5 }], + 'max-nested-callbacks': ['error', 4], + 'max-statements': 'off', + 'max-statements-per-line': ['error', { max: 1 }], + 'new-cap': ['error', { capIsNewExceptions: ['$.Deferred', 'DragDropContext', 'DragLayer', 'DragSource', 'DropTarget'] }], + 'new-parens': 'error', + 'newline-after-var': 'off', + 'newline-before-return': 'off', + 'newline-per-chained-call': 'off', + 'no-array-constructor': 'error', + 'no-bitwise': 'error', + 'no-continue': 'error', + 'no-inline-comments': 'off', + 'no-lonely-if': 'warn', + 'no-mixed-spaces-and-tabs': 'error', + 'no-multiple-empty-lines': ['error', { max: 1 }], + 'no-negated-condition': 'warn', + 'no-nested-ternary': 'error', + 'no-new-object': 'error', + 'no-plusplus': 'off', + 'no-restricted-syntax': 'off', + 'no-spaced-func': 'error', + 'no-ternary': 'off', + 'no-trailing-spaces': 'error', + 'no-underscore-dangle': ['error', { allowAfterThis: true }], + 'no-unneeded-ternary': 'error', + 'no-whitespace-before-property': 'error', + 'object-curly-spacing': ['error', 'always'], + 'one-var': ['error', 'never'], + 'one-var-declaration-per-line': ['error', 'always'], + 'operator-assignment': ['off', 'never'], + 'operator-linebreak': ['error', 'after'], + 'quote-props': ['error', 'as-needed'], + quotes: ['error', 'single'], + 'require-jsdoc': 'off', + semi: 'error', + 'semi-spacing': ['error', { before: false, after: true }], + 'sort-vars': 'off', + 'space-before-blocks': ['error', 'always'], + 'space-before-function-paren': ['error', 'never'], + 'space-in-parens': 'off', + 'space-infix-ops': 'off', + 'space-unary-ops': 'off', + 'spaced-comment': 'error', + 'wrap-regex': 'error', + + // ImportSort + + 'simple-import-sort/imports': 'error', + 'import/newline-after-import': 'error', + + // React + + 'react/jsx-boolean-value': [2, 'always'], + 'react/jsx-uses-vars': 2, + 'react/jsx-closing-bracket-location': 2, + 'react/jsx-tag-spacing': ['error'], + 'react/jsx-curly-spacing': [2, 'never'], + 'react/jsx-equals-spacing': [2, 'never'], + 'react/jsx-indent-props': [2, 2], + 'react/jsx-indent': [2, 2, { indentLogicalExpressions: true }], + 'react/jsx-key': 2, + 'react/jsx-no-bind': [2, { allowArrowFunctions: true }], + 'react/jsx-no-duplicate-props': [2, { ignoreCase: true }], + 'react/jsx-max-props-per-line': [2, { maximum: 2 }], + 'react/jsx-handler-names': [2, { eventHandlerPrefix: '(on|dispatch)', eventHandlerPropPrefix: 'on' }], + 'react/jsx-no-undef': 2, + 'react/jsx-pascal-case': 2, + 'react/jsx-uses-react': 2, + // Explicitly disabled in case we want to enable them again + 'react/no-did-mount-set-state': 0, + 'react/no-did-update-set-state': 0, + 'react/no-direct-mutation-state': 2, + 'react/no-multi-comp': [2, { ignoreStateless: true }], + 'react/no-unknown-property': 2, + 'react/prefer-es6-class': 2, + 'react/prop-types': 2, + 'react/react-in-jsx-scope': 2, + 'react/self-closing-comp': 2, + 'react/sort-comp': 2, + 'react/jsx-wrap-multilines': 2, + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'error' + }, + overrides: [ + { + files: [ + '*.js' + ], + rules: { + 'simple-import-sort/imports': [ + 'error', + { + groups: [ + // Packages + // Absolute Paths + // Relative Paths + // Css + ['^@?\\w', `^(${dirs})(/.*|$)`, '^\\.', '^\\..*css$'] + ] + } + ] + } + }, + { + files: [ + '*.ts', + '*.tsx' + ], + + parser: '@typescript-eslint/parser', + parserOptions: { + project: './tsconfig.json' + }, + + extends: [ + 'prettier' + ], + + rules: Object.assign(typescriptEslintRecommended.rules, { + 'no-shadow': 'off', + // These should be enabled after cleaning things up + '@typescript-eslint/no-unused-vars': 'warn', + '@typescript-eslint/explicit-function-return-type': 'off', + 'react/prop-types': 'off', + 'prettier/prettier': 'error', + 'simple-import-sort/imports': [ + 'error', + { + groups: [ + // Packages + // Absolute Paths + // Relative Paths + // Css + ['^@?\\w', `^(${dirs})(/.*|$)`, '^\\.', '^\\..*css$'] + ] + } + ] + }) + }, + { + files: [ + '*.css.d.ts' + ], + rules: { + 'filenames/match-exported': 'off', + 'init-declarations': 'off', + 'prettier/prettier': 'off' + } + } + ] +}; diff --git a/frontend/.jsbeautifyrc b/frontend/.jsbeautifyrc deleted file mode 100644 index 50aa6aa29..000000000 --- a/frontend/.jsbeautifyrc +++ /dev/null @@ -1,12 +0,0 @@ -{ - "js": { - "indent_size": 2, - "indent_char": " ", - "indent_level": 2, - "indent_with_tabs": false, - "preserve_newlines": true, - "brace_style": "collapse", - "max_preserve_newlines": 2, - "jslint_happy": true - } -} \ No newline at end of file diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 000000000..3e6367c54 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,10 @@ +# Ignore everything recursively +* + +# But not the .ts files +!*.ts* + +*css.d.ts + +# Check subdirectories too +!*/ diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 000000000..2f91ee691 --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "arrowParens": "always", + "endOfLine": "auto", + "singleQuote": true, + "trailingComma": "es5" +} diff --git a/frontend/.stylelintrc b/frontend/.stylelintrc index acbd3210e..f19357a4c 100644 --- a/frontend/.stylelintrc +++ b/frontend/.stylelintrc @@ -1,11 +1,12 @@ { -"plugins": [ - "stylelint-order" -], -"ignoreFiles": [ - "frontend/src/Styles/scaffolding.css" -], -"rules": { + "plugins": [ + "stylelint-order" + ], + "ignoreFiles": [ + "frontend/src/Styles/scaffolding.css", + "**/*.js" + ], + "rules": { "at-rule-empty-line-before": [ "always", { @@ -14,9 +15,6 @@ ] } ], - "at-rule-name-case": "lower", - "at-rule-name-newline-after": "always-multi-line", - "at-rule-name-space-after": "always", "at-rule-no-unknown": [ true, { @@ -27,83 +25,36 @@ } ], "at-rule-no-vendor-prefix": true, - "at-rule-semicolon-newline-after": "always", - "at-rule-semicolon-space-before": "never", - "block-closing-brace-empty-line-before": "never", - "block-closing-brace-newline-after": "always", - "block-closing-brace-newline-before": "always", - "block-closing-brace-space-after": "always-single-line", - "block-closing-brace-space-before": "always-single-line", "block-no-empty": true, - "block-opening-brace-newline-after": "always", - "block-opening-brace-newline-before": "never-single-line", - "block-opening-brace-space-after": "always-single-line", - "block-opening-brace-space-before": "always", - "color-hex-case": "lower", "color-hex-length": "short", "color-named": "never", "color-no-invalid-hex": true, "comment-whitespace-inside": "always", - "declaration-bang-space-after": "never", - "declaration-bang-space-before": "always", "declaration-block-no-duplicate-properties": [ true, { "ignoreProperties": [ - "composes" + "composes" ] } ], "declaration-block-no-redundant-longhand-properties": true, "declaration-block-no-shorthand-property-overrides": true, - "declaration-block-semicolon-newline-after": "always", - "declaration-block-semicolon-newline-before": "never-multi-line", - "declaration-block-semicolon-space-before": "never", "declaration-block-single-line-max-declarations": 1, - "declaration-block-trailing-semicolon": "always", - "declaration-colon-space-after": "always", - "declaration-colon-space-before": "never", "font-family-name-quotes": "always-unless-keyword", "function-calc-no-unspaced-operator": true, - "function-comma-newline-after": "never-multi-line", - "function-comma-newline-before": "never-multi-line", - "function-comma-space-after": "always", - "function-comma-space-before": "never", "function-linear-gradient-no-nonstandard-direction": true, "function-name-case": "lower", - "function-parentheses-newline-inside": "never-multi-line", - "function-parentheses-space-inside": "never", "function-url-quotes": "always", - "function-url-scheme-blacklist": [ + "function-url-scheme-disallowed-list": [ "data" ], - "function-whitespace-after": "always", - "indentation": 2, "keyframe-declaration-no-important": true, "length-zero-no-unit": true, - "max-empty-lines": 1, - "max-line-length": [ - 100, - { - "ignore": [ - "non-comments" - ] - } - ], "max-nesting-depth": 2, - "media-feature-colon-space-after": "always", - "media-feature-colon-space-before": "never", - "media-feature-name-case": "lower", "media-feature-name-no-vendor-prefix": true, - "media-feature-range-operator-space-after": "always", - "media-feature-range-operator-space-before": "always", "no-empty-source": true, - "no-eol-whitespace": true, - "no-extra-semicolons": true, "no-invalid-double-slash-comments": true, - "no-missing-end-of-source-newline": true, - "number-leading-zero": "always", - "number-no-trailing-zeros": true, "order/order": [ "custom-properties", "dollar-variables", @@ -131,6 +82,7 @@ "right", "bottom", "left", + "inset", "z-index", "display", "visibility", @@ -342,54 +294,33 @@ ] } ], - "property-case": "lower", "property-no-vendor-prefix": true, "rule-empty-line-before": [ "always", { "except": [ - "first-nested" + "first-nested" ], "ignore": [ - "after-comment" + "after-comment" ] } ], - "selector-attribute-brackets-space-inside": "never", - "selector-attribute-operator-space-after": "never", - "selector-attribute-operator-space-before": "never", "selector-attribute-quotes": "never", "selector-class-pattern": "^[A-Za-z0-9]+$", - "selector-combinator-space-after": "always", - "selector-combinator-space-before": "always", - "selector-descendant-combinator-no-non-space": true, - "selector-list-comma-newline-after": "always", - "selector-list-comma-newline-before": "never-multi-line", - "selector-list-comma-space-before": "never", "selector-max-attribute": 0, "selector-max-class": 3, "selector-max-compound-selectors": 3, - "selector-max-empty-lines": 0, "selector-max-id": 0, "selector-max-universal": 0, - "selector-pseudo-class-case": "lower", - "selector-pseudo-class-parentheses-space-inside": "never", - "selector-pseudo-element-case": "lower", "selector-pseudo-element-colon-notation": "double", "selector-pseudo-element-no-unknown": true, "selector-type-case": "lower", "selector-type-no-unknown": true, "shorthand-property-no-redundant-values": true, "string-no-newline": true, - "string-quotes": "single", "time-min-milliseconds": 100, - "unit-case": "lower", "unit-no-unknown": true, - "value-list-comma-newline-after": "never-multi-line", - "value-list-comma-newline-before": "never-multi-line", - "value-list-comma-space-after": "always", - "value-list-comma-space-before": "never", - "value-list-max-empty-lines": 0, "value-no-vendor-prefix": true } -} +} \ No newline at end of file diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 000000000..0e005a3cd --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "stylelint.vscode-stylelint", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode" + ] +} \ No newline at end of file diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 000000000..8da95337f --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,23 @@ +// Place your settings in this file to overwrite default and user settings. +{ + "files.insertFinalNewline": true, + + "files.exclude": { + "**/node_modules": true, + "**/*.d.css": true + }, + + "editor.formatOnSave": false, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit" + }, + + "typescript.preferences.quoteStyle": "single", + + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact" + ], +} diff --git a/frontend/babel.config.js b/frontend/babel.config.js new file mode 100644 index 000000000..ade9f24a2 --- /dev/null +++ b/frontend/babel.config.js @@ -0,0 +1,39 @@ +const loose = true; + +module.exports = { + plugins: [ + '@babel/plugin-transform-logical-assignment-operators', + + // Stage 1 + '@babel/plugin-proposal-export-default-from', + ['@babel/plugin-transform-optional-chaining', { loose }], + ['@babel/plugin-transform-nullish-coalescing-operator', { loose }], + + // Stage 2 + '@babel/plugin-transform-export-namespace-from', + + // Stage 3 + ['@babel/plugin-transform-class-properties', { loose }], + '@babel/plugin-syntax-dynamic-import' + ], + env: { + development: { + presets: [ + ['@babel/preset-react', { development: true }], + '@babel/preset-typescript' + ], + plugins: [ + 'babel-plugin-inline-classnames' + ] + }, + production: { + presets: [ + '@babel/preset-react', + '@babel/preset-typescript' + ], + plugins: [ + 'babel-plugin-transform-react-remove-prop-types' + ] + } + } +}; diff --git a/frontend/build/webpack.config.js b/frontend/build/webpack.config.js new file mode 100644 index 000000000..d1873380e --- /dev/null +++ b/frontend/build/webpack.config.js @@ -0,0 +1,291 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const path = require('path'); +const webpack = require('webpack'); +const FileManagerPlugin = require('filemanager-webpack-plugin'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const LiveReloadPlugin = require('webpack-livereload-plugin'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const TerserPlugin = require('terser-webpack-plugin'); +const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); + +module.exports = (env) => { + const uiFolder = 'UI'; + const frontendFolder = path.join(__dirname, '..'); + const srcFolder = path.join(frontendFolder, 'src'); + const isProduction = !!env.production; + const isProfiling = isProduction && !!env.profile; + const inlineWebWorkers = 'no-fallback'; + + const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder); + + console.log('Source Folder:', srcFolder); + console.log('Output Folder:', distFolder); + console.log('isProduction:', isProduction); + console.log('isProfiling:', isProfiling); + + const config = { + mode: isProduction ? 'production' : 'development', + devtool: isProduction ? 'source-map' : 'eval-source-map', + target: 'web', + + stats: { + children: false + }, + + watchOptions: { + ignored: /node_modules/ + }, + + entry: { + index: 'index.ts' + }, + + resolve: { + extensions: [ + '.ts', + '.tsx', + '.js' + ], + modules: [ + srcFolder, + path.join(srcFolder, 'Shims'), + 'node_modules' + ], + alias: { + jquery: 'jquery/dist/jquery.min', + 'react-middle-truncate': 'react-middle-truncate/lib/react-middle-truncate' + }, + fallback: { + buffer: false, + http: false, + https: false, + url: false, + util: false, + net: false + } + }, + + output: { + path: distFolder, + publicPath: '/', + filename: isProduction ? '[name]-[contenthash].js' : '[name].js', + sourceMapFilename: '[file].map' + }, + + optimization: { + moduleIds: 'deterministic', + chunkIds: isProduction ? 'deterministic' : 'named' + }, + + performance: { + hints: false + }, + + experiments: { + topLevelAwait: true + }, + + plugins: [ + new webpack.DefinePlugin({ + __DEV__: !isProduction, + 'process.env.NODE_ENV': isProduction ? JSON.stringify('production') : JSON.stringify('development') + }), + + new MiniCssExtractPlugin({ + filename: 'Content/styles.css', + chunkFilename: isProduction ? 'Content/[id]-[chunkhash].css' : 'Content/[id].css' + }), + + new HtmlWebpackPlugin({ + template: 'frontend/src/index.ejs', + filename: 'index.html', + publicPath: '/', + inject: false + }), + + new FileManagerPlugin({ + events: { + onEnd: { + copy: [ + // HTML + { + source: 'frontend/src/*.html', + destination: distFolder + }, + + // Fonts + { + source: 'frontend/src/Content/Fonts/*.*', + destination: path.join(distFolder, 'Content/Fonts') + }, + + // Icon Images + { + source: 'frontend/src/Content/Images/Icons/*.*', + destination: path.join(distFolder, 'Content/Images/Icons') + }, + + // Images + { + source: 'frontend/src/Content/Images/*.*', + destination: path.join(distFolder, 'Content/Images') + }, + + // Robots + { + source: 'frontend/src/Content/robots.txt', + destination: path.join(distFolder, 'Content/robots.txt') + }, + + // manifest.json and browserconfig.xml + { + source: 'frontend/src/Content/*.(json|xml)', + destination: path.join(distFolder, 'Content') + } + ] + } + } + }), + + new ForkTsCheckerWebpackPlugin(), + + new LiveReloadPlugin() + ], + + resolveLoader: { + modules: [ + 'node_modules', + 'frontend/build/webpack/' + ] + }, + + module: { + rules: [ + { + test: /\.worker\.js$/, + use: { + loader: 'worker-loader', + options: { + filename: '[name].js', + inline: inlineWebWorkers + } + } + }, + { + test: [/\.jsx?$/, /\.tsx?$/], + exclude: /(node_modules|JsLibraries)/, + use: [ + { + loader: 'babel-loader', + options: { + configFile: `${frontendFolder}/babel.config.js`, + envName: isProduction ? 'production' : 'development', + presets: [ + [ + '@babel/preset-env', + { + modules: false, + loose: true, + debug: false, + useBuiltIns: 'entry', + corejs: '3.41' + } + ] + ] + } + } + ] + }, + + // CSS Modules + { + test: /\.css$/, + exclude: /(node_modules|globals.css)/, + use: [ + { loader: MiniCssExtractPlugin.loader }, + { loader: 'css-modules-typescript-loader' }, + { + loader: 'css-loader', + options: { + importLoaders: 1, + modules: { + localIdentName: isProduction ? '[name]/[local]/[hash:base64:5]' : '[name]/[local]' + } + } + }, + { + loader: 'postcss-loader', + options: { + postcssOptions: { + config: 'frontend/postcss.config.js' + } + } + } + ] + }, + + // Global styles + { + test: /\.css$/, + include: /(node_modules|globals.css)/, + use: [ + 'style-loader', + { + loader: 'css-loader' + } + ] + }, + + // Fonts + { + test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, + use: [ + { + loader: 'url-loader', + options: { + limit: 10240, + mimetype: 'application/font-woff', + emitFile: false, + name: 'Content/Fonts/[name].[ext]' + } + } + ] + }, + + { + test: /\.(ttf|eot|eot?#iefix|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, + use: [ + { + loader: 'file-loader', + options: { + emitFile: false, + name: 'Content/Fonts/[name].[ext]' + } + } + ] + } + ] + } + }; + + if (isProfiling) { + config.resolve.alias['react-dom$'] = 'react-dom/profiling'; + config.resolve.alias['scheduler/tracing'] = 'scheduler/tracing-profiling'; + + config.optimization = { + minimize: true, + minimizer: [ + new TerserPlugin({ + terserOptions: { + sourceMap: true, // Must be set to true if using source-maps in production + mangle: false, + keep_classnames: true, + keep_fnames: true + } + }) + ] + }; + } + + return config; +}; diff --git a/frontend/gulp/webpack/css-variables-loader.js b/frontend/build/webpack/css-variables-loader.js similarity index 83% rename from frontend/gulp/webpack/css-variables-loader.js rename to frontend/build/webpack/css-variables-loader.js index 5683c98be..717d7d323 100644 --- a/frontend/gulp/webpack/css-variables-loader.js +++ b/frontend/build/webpack/css-variables-loader.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line filenames/match-exported const loaderUtils = require('loader-utils'); module.exports = function cssVariablesLoader(source) { diff --git a/frontend/gulp/build.js b/frontend/gulp/build.js deleted file mode 100644 index cfeb5d138..000000000 --- a/frontend/gulp/build.js +++ /dev/null @@ -1,15 +0,0 @@ -const gulp = require('gulp'); -const runSequence = require('run-sequence'); - -require('./clean'); -require('./copy'); - -gulp.task('build', () => { - return runSequence('clean', [ - 'webpack', - 'copyHtml', - 'copyFonts', - 'copyImages', - 'copyJs' - ]); -}); diff --git a/frontend/gulp/clean.js b/frontend/gulp/clean.js deleted file mode 100644 index ac2e4026f..000000000 --- a/frontend/gulp/clean.js +++ /dev/null @@ -1,8 +0,0 @@ -const gulp = require('gulp'); -const del = require('del'); - -const paths = require('./helpers/paths'); - -gulp.task('clean', () => { - return del([paths.dest.root]); -}); diff --git a/frontend/gulp/copy.js b/frontend/gulp/copy.js deleted file mode 100644 index d1d47c97e..000000000 --- a/frontend/gulp/copy.js +++ /dev/null @@ -1,45 +0,0 @@ -var path = require('path'); -var gulp = require('gulp'); -var print = require('gulp-print'); -var cache = require('gulp-cached'); -var livereload = require('gulp-livereload'); -var paths = require('./helpers/paths.js'); - -gulp.task('copyJs', () => { - return gulp.src( - [ - path.join(paths.src.root, 'polyfills.js') - ]) - .pipe(cache('copyJs')) - .pipe(print()) - .pipe(gulp.dest(paths.dest.root)) - .pipe(livereload()); -}); - -gulp.task('copyHtml', () => { - return gulp.src(paths.src.html) - .pipe(cache('copyHtml')) - .pipe(print()) - .pipe(gulp.dest(paths.dest.root)) - .pipe(livereload()); -}); - -gulp.task('copyFonts', () => { - return gulp.src( - path.join(paths.src.fonts, '**', '*.*') - ) - .pipe(cache('copyFonts')) - .pipe(print()) - .pipe(gulp.dest(paths.dest.fonts)) - .pipe(livereload()); -}); - -gulp.task('copyImages', () => { - return gulp.src( - path.join(paths.src.images, '**', '*.*') - ) - .pipe(cache('copyImages')) - .pipe(print()) - .pipe(gulp.dest(paths.dest.images)) - .pipe(livereload()); -}); diff --git a/frontend/gulp/gulpFile.js b/frontend/gulp/gulpFile.js deleted file mode 100644 index 744dd8d7e..000000000 --- a/frontend/gulp/gulpFile.js +++ /dev/null @@ -1,8 +0,0 @@ -require('./build.js'); -require('./clean.js'); -require('./copy.js'); -require('./imageMin.js'); -require('./start.js'); -require('./stripBom.js'); -require('./watch.js'); -require('./webpack.js'); diff --git a/frontend/gulp/helpers/errorHandler.js b/frontend/gulp/helpers/errorHandler.js deleted file mode 100644 index f3e1c113b..000000000 --- a/frontend/gulp/helpers/errorHandler.js +++ /dev/null @@ -1,6 +0,0 @@ -const gulpUtil = require('gulp-util'); - -module.exports = function errorHandler(error) { - gulpUtil.log(gulpUtil.colors.red(`Error (${error.plugin}): ${error.message}`)); - this.emit('end'); -}; diff --git a/frontend/gulp/helpers/html-annotate-loader.js b/frontend/gulp/helpers/html-annotate-loader.js deleted file mode 100644 index 6c7ce10b8..000000000 --- a/frontend/gulp/helpers/html-annotate-loader.js +++ /dev/null @@ -1,15 +0,0 @@ -const path = require('path'); -const rootPath = path.resolve(__dirname + '/../../src/'); -module.exports = function(source) { - if (this.cacheable) { - this.cacheable(); - } - - const resourcePath = this.resourcePath.replace(rootPath, ''); - const wrappedSource =` - - ${source} - `; - - return wrappedSource; -}; diff --git a/frontend/gulp/helpers/paths.js b/frontend/gulp/helpers/paths.js deleted file mode 100644 index b96b5aaeb..000000000 --- a/frontend/gulp/helpers/paths.js +++ /dev/null @@ -1,23 +0,0 @@ -const root = './frontend/src/'; - -const paths = { - src: { - root, - html: root + '*.html', - scripts: root + '**/*.js', - content: root + 'Content/', - fonts: root + 'Content/Fonts/', - images: root + 'Content/Images/', - exclude: { - libs: `!${root}JsLibraries/**` - } - }, - dest: { - root: './_output/UI/', - content: './_output/UI/Content/', - fonts: './_output/UI/Content/Fonts/', - images: './_output/UI/Content/Images/' - } -}; - -module.exports = paths; diff --git a/frontend/gulp/imageMin.js b/frontend/gulp/imageMin.js deleted file mode 100644 index 828143f28..000000000 --- a/frontend/gulp/imageMin.js +++ /dev/null @@ -1,15 +0,0 @@ -var gulp = require('gulp'); -var print = require('gulp-print'); -var paths = require('./helpers/paths.js'); - -gulp.task('imageMin', () => { - var imagemin = require('gulp-imagemin'); - return gulp.src(paths.src.images) - .pipe(imagemin({ - progressive: false, - optimizationLevel: 4, - svgoPlugins: [{ removeViewBox: false }] - })) - .pipe(print()) - .pipe(gulp.dest(paths.src.content + 'Images/')); -}); diff --git a/frontend/gulp/start.js b/frontend/gulp/start.js deleted file mode 100644 index e2f65660b..000000000 --- a/frontend/gulp/start.js +++ /dev/null @@ -1,104 +0,0 @@ -// will download and run sonarr (server) in a non-windows enviroment -// you can use this if you don't care about the server code and just want to work -// with the web code. - -var http = require('http'); -var gulp = require('gulp'); -var fs = require('fs'); -var targz = require('tar.gz'); -var del = require('del'); -var spawn = require('child_process').spawn; - -function download(url, dest, cb) { - console.log('Downloading ' + url + ' to ' + dest); - var file = fs.createWriteStream(dest); - http.get(url, function(response) { - response.pipe(file); - file.on('finish', function() { - console.log('Download completed'); - file.close(cb); - }); - }); -} - -function getLatest(cb) { - var branch = 'develop'; - process.argv.forEach(function(val) { - var branchMatch = /branch=([\S]*)/.exec(val); - if (branchMatch && branchMatch.length > 1) { - branch = branchMatch[1]; - } - }); - - var url = 'http://services.lidarr.audio/v1/update/' + branch + '?os=osx'; - - console.log('Checking for latest version:', url); - - http.get(url, function(res) { - var data = ''; - - res.on('data', function(chunk) { - data += chunk; - }); - - res.on('end', function() { - var updatePackage = JSON.parse(data).updatePackage; - console.log('Latest version available: ' + updatePackage.version + ' Release Date: ' + updatePackage.releaseDate); - cb(updatePackage); - }); - }).on('error', function(e) { - console.log('problem with request: ' + e.message); - }); -} - -function extract(source, dest, cb) { - console.log('extracting download page to ' + dest); - new targz().extract(source, dest, function(err) { - if (err) { - console.log(err); - } - console.log('Update package extracted.'); - cb(); - }); -} - -gulp.task('getSonarr', function() { - try { - fs.mkdirSync('./_start/'); - } catch (e) { - if (e.code !== 'EEXIST') { - throw e; - } - } - - getLatest(function(updatePackage) { - var packagePath = './_start/' + updatePackage.filename; - var dirName = './_start/' + updatePackage.version; - download(updatePackage.url, packagePath, function() { - extract(packagePath, dirName, function() { - // clean old binaries - console.log('Cleaning old binaries'); - del.sync(['./_output/*', '!./_output/UI/']); - console.log('copying binaries to target'); - gulp.src(dirName + '/Lidarr/*.*') - .pipe(gulp.dest('./_output/')); - }); - }); - }); -}); - -gulp.task('startSonarr', function() { - var ls = spawn('mono', ['--debug', './_output/Lidarr.exe']); - - ls.stdout.on('data', function(data) { - process.stdout.write(data); - }); - - ls.stderr.on('data', function(data) { - process.stdout.write(data); - }); - - ls.on('close', function(code) { - console.log('child process exited with code ' + code); - }); -}); diff --git a/frontend/gulp/stripBom.js b/frontend/gulp/stripBom.js deleted file mode 100644 index 080b86dfe..000000000 --- a/frontend/gulp/stripBom.js +++ /dev/null @@ -1,13 +0,0 @@ -const gulp = require('gulp'); -const paths = require('./helpers/paths.js'); -const stripbom = require('gulp-stripbom'); - -function stripBom(dest) { - gulp.src([paths.src.scripts, paths.src.exclude.libs]) - .pipe(stripbom({ showLog: false })) - .pipe(gulp.dest(dest)); -} - -gulp.task('stripBom', () => { - stripBom(paths.src.root); -}); diff --git a/frontend/gulp/watch.js b/frontend/gulp/watch.js deleted file mode 100644 index dae893c38..000000000 --- a/frontend/gulp/watch.js +++ /dev/null @@ -1,27 +0,0 @@ -var gulp = require('gulp'); -var livereload = require('gulp-livereload'); -var watch = require('gulp-watch'); -var paths = require('./helpers/paths.js'); - -require('./copy.js'); -require('./webpack.js'); - -function watchTask(glob, task) { - var options = { - name: `watch: ${task}`, - verbose: true - }; - return watch(glob, options, () => { - gulp.start(task); - }); -} - -gulp.task('watch', ['copyHtml', 'copyFonts', 'copyImages', 'copyJs'], () => { - livereload.listen(); - - gulp.start('webpackWatch'); - - watchTask(paths.src.html, 'copyHtml'); - watchTask(paths.src.fonts + '**/*.*', 'copyFonts'); - watchTask(paths.src.images + '**/*.*', 'copyImages'); -}); diff --git a/frontend/gulp/webpack.js b/frontend/gulp/webpack.js deleted file mode 100644 index 5faec4c87..000000000 --- a/frontend/gulp/webpack.js +++ /dev/null @@ -1,195 +0,0 @@ -const gulp = require('gulp'); -const webpackStream = require('webpack-stream'); -const livereload = require('gulp-livereload'); -const path = require('path'); -const webpack = require('webpack'); -const errorHandler = require('./helpers/errorHandler'); -const ExtractTextPlugin = require('extract-text-webpack-plugin'); - -const uiFolder = 'UI'; -const root = path.join(__dirname, '..', 'src'); -const isProduction = process.argv.indexOf('--production') > -1; - -console.log('ROOT:', root); -console.log('isProduction:', isProduction); - -const cssVarsFiles = [ - '../src/Styles/Variables/colors', - '../src/Styles/Variables/dimensions', - '../src/Styles/Variables/fonts', - '../src/Styles/Variables/animations' -].map(require.resolve); - -const extractCSSPlugin = new ExtractTextPlugin({ - filename: path.join('_output', uiFolder, 'Content', 'styles.css'), - allChunks: true, - disable: false, - ignoreOrder: true -}); - -const config = { - devtool: '#source-map', - stats: { - children: false - }, - watchOptions: { - ignored: /node_modules/ - }, - entry: { - preload: 'preload.js', - vendor: 'vendor.js', - index: 'index.js' - }, - resolve: { - modules: [ - root, - path.join(root, 'Shims'), - 'node_modules' - ], - alias: { - jquery: 'jquery/src/jquery' - } - }, - output: { - filename: path.join('_output', uiFolder, '[name].js'), - sourceMapFilename: '[file].map' - }, - plugins: [ - extractCSSPlugin, - new webpack.optimize.CommonsChunkPlugin({ - name: 'vendor' - }), - - new webpack.DefinePlugin({ - __DEV__: !isProduction, - 'process.env': { - NODE_ENV: isProduction ? JSON.stringify('production') : JSON.stringify('development') - } - }) - ], - resolveLoader: { - modules: [ - 'node_modules', - 'frontend/gulp/webpack/' - ] - }, - // TODO: Do we need this loader? - // eslint: { - // formatter: function(results) { - // return JSON.stringify(results); - // } - // }, - module: { - rules: [ - { - test: /\.js?$/, - exclude: /(node_modules|JsLibraries)/, - loader: 'babel-loader', - query: { - plugins: ['transform-class-properties'], - presets: ['es2015', 'decorators-legacy', 'react', 'stage-2'], - env: { - development: { - plugins: ['transform-react-jsx-source'] - } - } - } - }, - - // CSS Modules - { - test: /\.css$/, - exclude: /(node_modules|globals.css)/, - use: extractCSSPlugin.extract({ - fallback: 'style-loader', - use: [ - { - loader: 'css-variables-loader', - options: { - cssVarsFiles - } - }, - { - loader: 'css-loader', - options: { - modules: true, - importLoaders: 1, - localIdentName: '[name]-[local]-[hash:base64:5]', - sourceMap: true - } - }, - { - loader: 'postcss-loader', - options: { - config: { - ctx: { - cssVarsFiles - }, - path: 'frontend/postcss.config.js' - } - } - } - ] - }) - }, - - // Global styles - { - test: /\.css$/, - include: /(node_modules|globals.css)/, - use: [ - 'style-loader', - { - loader: 'css-loader' - } - ] - }, - - // Fonts - { - test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, - use: [ - { - loader: 'url-loader', - options: { - limit: 10240, - mimetype: 'application/font-woff', - emitFile: false, - name: 'Content/Fonts/[name].[ext]' - } - } - ] - }, - - { - test: /\.(ttf|eot|eot?#iefix|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, - use: [ - { - loader: 'file-loader', - options: { - emitFile: false, - name: 'Content/Fonts/[name].[ext]' - } - } - ] - } - ] - } -}; - -gulp.task('webpack', () => { - return gulp.src('index.js') - .pipe(webpackStream(config)) - .pipe(gulp.dest('')); -}); - -gulp.task('webpackWatch', () => { - config.watch = true; - return gulp.src('') - .pipe(webpackStream(config)) - .on('error', errorHandler) - .pipe(gulp.dest('')) - .on('error', errorHandler) - .pipe(livereload()) - .on('error', errorHandler); -}); diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json new file mode 100644 index 000000000..329edb2e3 --- /dev/null +++ b/frontend/jsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es6", + "checkJs": false, + "baseUrl": "src", + "jsx": "react", + "module": "commonjs", + "moduleResolution": "node", + "paths": { + "*": [ + "*" + ] + } + }, + "include": [ + "./src/**/*" + ], + "exclude": [ + ] +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index f82554ba8..89db00f8c 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,33 +1,32 @@ const reload = require('require-nocache')(module); -module.exports = (ctx, configPath, options) => { - const config = { - plugins: { - 'postcss-mixins': { - mixinsDir: [ - 'frontend/src/Styles/Mixins' - ] - }, - 'postcss-simple-vars': { - variables: () => - ctx.options.cssVarsFiles.reduce((acc, vars) => { - return Object.assign(acc, reload(vars)); - }, {}) - }, - 'postcss-nested': {}, - autoprefixer: { - browsers: [ - 'Chrome >= 30', - 'Firefox >= 30', - 'Safari >= 6', - 'Edge >= 12', - 'Explorer >= 11', - 'iOS >= 7', - 'Android >= 4.4' - ] - } - } - }; +const cssVarsFiles = [ + './src/Styles/Variables/dimensions', + './src/Styles/Variables/fonts', + './src/Styles/Variables/animations', + './src/Styles/Variables/zIndexes' +].map(require.resolve); - return config; +const mixinsFiles = [ + 'frontend/src/Styles/Mixins/cover.css', + 'frontend/src/Styles/Mixins/linkOverlay.css', + 'frontend/src/Styles/Mixins/scroller.css', + 'frontend/src/Styles/Mixins/truncate.css' +]; + +module.exports = { + plugins: [ + 'autoprefixer', + ['postcss-mixins', { + mixinsFiles + }], + ['postcss-simple-vars', { + variables: () => + cssVarsFiles.reduce((acc, vars) => { + return Object.assign(acc, reload(vars)); + }, {}) + }], + 'postcss-color-function', + 'postcss-nested' + ] }; diff --git a/frontend/src/.vscode/settings.json b/frontend/src/.vscode/settings.json deleted file mode 100644 index 0fb2bf460..000000000 --- a/frontend/src/.vscode/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -// Place your settings in this file to overwrite default and user settings. -{ - "files.insertFinalNewline": true -} \ No newline at end of file diff --git a/frontend/src/Activity/Blacklist/Blacklist.js b/frontend/src/Activity/Blacklist/Blacklist.js deleted file mode 100644 index e3ecd2ff7..000000000 --- a/frontend/src/Activity/Blacklist/Blacklist.js +++ /dev/null @@ -1,110 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { icons } from 'Helpers/Props'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import TablePager from 'Components/Table/TablePager'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; -import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; -import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; -import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; -import BlacklistRowConnector from './BlacklistRowConnector'; - -class Blacklist extends Component { - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - items, - columns, - totalRecords, - isClearingBlacklistExecuting, - onClearBlacklistPress, - ...otherProps - } = this.props; - - return ( - - - - - - - - - { - isFetching && !isPopulated && - - } - - { - !isFetching && !!error && -
Unable to load blacklist
- } - - { - isPopulated && !error && !items.length && -
- No history blacklist -
- } - - { - isPopulated && !error && !!items.length && -
- - - { - items.map((item) => { - return ( - - ); - }) - } - -
- - -
- } -
-
- ); - } -} - -Blacklist.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - totalRecords: PropTypes.number, - isClearingBlacklistExecuting: PropTypes.bool.isRequired, - onClearBlacklistPress: PropTypes.func.isRequired -}; - -export default Blacklist; diff --git a/frontend/src/Activity/Blacklist/BlacklistConnector.js b/frontend/src/Activity/Blacklist/BlacklistConnector.js deleted file mode 100644 index 0528dffaa..000000000 --- a/frontend/src/Activity/Blacklist/BlacklistConnector.js +++ /dev/null @@ -1,133 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; -import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; -import * as blacklistActions from 'Store/Actions/blacklistActions'; -import { executeCommand } from 'Store/Actions/commandActions'; -import * as commandNames from 'Commands/commandNames'; -import Blacklist from './Blacklist'; - -function createMapStateToProps() { - return createSelector( - (state) => state.blacklist, - createCommandsSelector(), - (blacklist, commands) => { - const isClearingBlacklistExecuting = _.some(commands, { name: commandNames.CLEAR_BLACKLIST }); - - return { - isClearingBlacklistExecuting, - ...blacklist - }; - } - ); -} - -const mapDispatchToProps = { - ...blacklistActions, - executeCommand -}; - -class BlacklistConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - registerPagePopulator(this.repopulate); - this.props.gotoBlacklistFirstPage(); - } - - componentDidUpdate(prevProps) { - if (prevProps.isClearingBlacklistExecuting && !this.props.isClearingBlacklistExecuting) { - this.props.gotoBlacklistFirstPage(); - } - } - - componentWillUnmount() { - unregisterPagePopulator(this.repopulate); - } - - // - // Control - - repopulate = () => { - this.props.fetchBlacklist(); - } - - // - // Listeners - - onFirstPagePress = () => { - this.props.gotoBlacklistFirstPage(); - } - - onPreviousPagePress = () => { - this.props.gotoBlacklistPreviousPage(); - } - - onNextPagePress = () => { - this.props.gotoBlacklistNextPage(); - } - - onLastPagePress = () => { - this.props.gotoBlacklistLastPage(); - } - - onPageSelect = (page) => { - this.props.gotoBlacklistPage({ page }); - } - - onSortPress = (sortKey) => { - this.props.setBlacklistSort({ sortKey }); - } - - onTableOptionChange = (payload) => { - this.props.setBlacklistTableOption(payload); - - if (payload.pageSize) { - this.props.gotoBlacklistFirstPage(); - } - } - - onClearBlacklistPress = () => { - this.props.executeCommand({ name: commandNames.CLEAR_BLACKLIST }); - } - - // - // Render - - render() { - return ( - - ); - } -} - -BlacklistConnector.propTypes = { - isClearingBlacklistExecuting: PropTypes.bool.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - fetchBlacklist: PropTypes.func.isRequired, - gotoBlacklistFirstPage: PropTypes.func.isRequired, - gotoBlacklistPreviousPage: PropTypes.func.isRequired, - gotoBlacklistNextPage: PropTypes.func.isRequired, - gotoBlacklistLastPage: PropTypes.func.isRequired, - gotoBlacklistPage: PropTypes.func.isRequired, - setBlacklistSort: PropTypes.func.isRequired, - setBlacklistTableOption: PropTypes.func.isRequired, - executeCommand: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(BlacklistConnector); diff --git a/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js b/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js deleted file mode 100644 index 356512a9d..000000000 --- a/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js +++ /dev/null @@ -1,89 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Button from 'Components/Link/Button'; -import DescriptionList from 'Components/DescriptionList/DescriptionList'; -import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; -import Modal from 'Components/Modal/Modal'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalFooter from 'Components/Modal/ModalFooter'; - -class BlacklistDetailsModal extends Component { - - // - // Render - - render() { - const { - isOpen, - sourceTitle, - protocol, - indexer, - message, - onModalClose - } = this.props; - - return ( - - - - Details - - - - - - - - - { - !!message && - - } - - { - !!message && - - } - - - - - - - - - ); - } -} - -BlacklistDetailsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - sourceTitle: PropTypes.string.isRequired, - protocol: PropTypes.string.isRequired, - indexer: PropTypes.string, - message: PropTypes.string, - onModalClose: PropTypes.func.isRequired -}; - -export default BlacklistDetailsModal; diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.css b/frontend/src/Activity/Blacklist/BlacklistRow.css deleted file mode 100644 index 030dfe98a..000000000 --- a/frontend/src/Activity/Blacklist/BlacklistRow.css +++ /dev/null @@ -1,18 +0,0 @@ -.language, -.quality { - composes: cell from 'Components/Table/Cells/TableRowCell.css'; - - width: 100px; -} - -.indexer { - composes: cell from 'Components/Table/Cells/TableRowCell.css'; - - width: 80px; -} - -.details { - composes: cell from 'Components/Table/Cells/TableRowCell.css'; - - width: 30px; -} diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.js b/frontend/src/Activity/Blacklist/BlacklistRow.js deleted file mode 100644 index f67e69723..000000000 --- a/frontend/src/Activity/Blacklist/BlacklistRow.js +++ /dev/null @@ -1,177 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { icons } from 'Helpers/Props'; -import IconButton from 'Components/Link/IconButton'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; -import TableRow from 'Components/Table/TableRow'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import EpisodeLanguage from 'Album/EpisodeLanguage'; -import EpisodeQuality from 'Album/EpisodeQuality'; -import ArtistNameLink from 'Artist/ArtistNameLink'; -import BlacklistDetailsModal from './BlacklistDetailsModal'; -import styles from './BlacklistRow.css'; - -class BlacklistRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isDetailsModalOpen: false - }; - } - - // - // Listeners - - onDetailsPress = () => { - this.setState({ isDetailsModalOpen: true }); - } - - onDetailsModalClose = () => { - this.setState({ isDetailsModalOpen: false }); - } - - // - // Render - - render() { - const { - artist, - sourceTitle, - language, - quality, - date, - protocol, - indexer, - message, - columns - } = this.props; - - return ( - - { - columns.map((column) => { - const { - name, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'artist.sortName') { - return ( - - - - ); - } - - if (name === 'sourceTitle') { - return ( - - {sourceTitle} - - ); - } - - if (name === 'language') { - return ( - - - - ); - } - - if (name === 'quality') { - return ( - - - - ); - } - - if (name === 'date') { - return ( - - ); - } - - if (name === 'indexer') { - return ( - - {indexer} - - ); - } - - if (name === 'details') { - return ( - - - - ); - } - - return null; - }) - } - - - - ); - } - -} - -BlacklistRow.propTypes = { - id: PropTypes.number.isRequired, - artist: PropTypes.object.isRequired, - sourceTitle: PropTypes.string.isRequired, - language: PropTypes.object.isRequired, - quality: PropTypes.object.isRequired, - date: PropTypes.string.isRequired, - protocol: PropTypes.string.isRequired, - indexer: PropTypes.string, - message: PropTypes.string, - columns: PropTypes.arrayOf(PropTypes.object).isRequired -}; - -export default BlacklistRow; diff --git a/frontend/src/Activity/Blacklist/BlacklistRowConnector.js b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js deleted file mode 100644 index f4f9217bf..000000000 --- a/frontend/src/Activity/Blacklist/BlacklistRowConnector.js +++ /dev/null @@ -1,17 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createArtistSelector from 'Store/Selectors/createArtistSelector'; -import BlacklistRow from './BlacklistRow'; - -function createMapStateToProps() { - return createSelector( - createArtistSelector(), - (artist) => { - return { - artist - }; - } - ); -} - -export default connect(createMapStateToProps)(BlacklistRow); diff --git a/frontend/src/Activity/Blocklist/Blocklist.js b/frontend/src/Activity/Blocklist/Blocklist.js new file mode 100644 index 000000000..ab43c106d --- /dev/null +++ b/frontend/src/Activity/Blocklist/Blocklist.js @@ -0,0 +1,268 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Alert from 'Components/Alert'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import TablePager from 'Components/Table/TablePager'; +import { align, icons, kinds } from 'Helpers/Props'; +import getRemovedItems from 'Utilities/Object/getRemovedItems'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import BlocklistRowConnector from './BlocklistRowConnector'; + +class Blocklist extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isConfirmRemoveModalOpen: false, + isConfirmClearModalOpen: false, + items: props.items + }; + } + + componentDidUpdate(prevProps) { + const { + items + } = this.props; + + if (hasDifferentItems(prevProps.items, items)) { + this.setState((state) => { + return { + ...removeOldSelectedState(state, getRemovedItems(prevProps.items, items)), + items + }; + }); + + return; + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + }; + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + }; + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + }; + + onRemoveSelectedPress = () => { + this.setState({ isConfirmRemoveModalOpen: true }); + }; + + onRemoveSelectedConfirmed = () => { + this.props.onRemoveSelected(this.getSelectedIds()); + this.setState({ isConfirmRemoveModalOpen: false }); + }; + + onConfirmRemoveModalClose = () => { + this.setState({ isConfirmRemoveModalOpen: false }); + }; + + onClearBlocklistPress = () => { + this.setState({ isConfirmClearModalOpen: true }); + }; + + onClearBlocklistConfirmed = () => { + this.props.onClearBlocklistPress(); + this.setState({ isConfirmClearModalOpen: false }); + }; + + onConfirmClearModalClose = () => { + this.setState({ isConfirmClearModalOpen: false }); + }; + + // + // Render + + render() { + const { + isFetching, + isPopulated, + isArtistFetching, + isArtistPopulated, + error, + items, + columns, + totalRecords, + isRemoving, + isClearingBlocklistExecuting, + ...otherProps + } = this.props; + + const isAllPopulated = isPopulated && isArtistPopulated; + const isAnyFetching = isFetching || isArtistFetching; + + const { + allSelected, + allUnselected, + selectedState, + isConfirmRemoveModalOpen, + isConfirmClearModalOpen + } = this.state; + + const selectedIds = this.getSelectedIds(); + + return ( + + + + + + + + + + + + + + + + + { + isAnyFetching && !isAllPopulated && + + } + + { + !isAnyFetching && !!error && + + {translate('UnableToLoadBlocklist')} + + } + + { + isAllPopulated && !error && !items.length && + + {translate('NoHistoryBlocklist')} + + } + + { + isAllPopulated && !error && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + +
+ } +
+ + + + +
+ ); + } +} + +Blocklist.propTypes = { + isArtistFetching: PropTypes.bool.isRequired, + isArtistPopulated: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isRemoving: PropTypes.bool.isRequired, + isClearingBlocklistExecuting: PropTypes.bool.isRequired, + onRemoveSelected: PropTypes.func.isRequired, + onClearBlocklistPress: PropTypes.func.isRequired +}; + +export default Blocklist; diff --git a/frontend/src/Activity/Blocklist/BlocklistConnector.js b/frontend/src/Activity/Blocklist/BlocklistConnector.js new file mode 100644 index 000000000..d810d1c0f --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistConnector.js @@ -0,0 +1,155 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import * as commandNames from 'Commands/commandNames'; +import withCurrentPage from 'Components/withCurrentPage'; +import * as blocklistActions from 'Store/Actions/blocklistActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import Blocklist from './Blocklist'; + +function createMapStateToProps() { + return createSelector( + (state) => state.blocklist, + (state) => state.artist, + createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST), + (blocklist, artist, isClearingBlocklistExecuting) => { + return { + isArtistFetching: artist.isFetching, + isArtistPopulated: artist.isPopulated, + isClearingBlocklistExecuting, + ...blocklist + }; + } + ); +} + +const mapDispatchToProps = { + ...blocklistActions, + executeCommand +}; + +class BlocklistConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchBlocklist, + gotoBlocklistFirstPage + } = this.props; + + registerPagePopulator(this.repopulate); + + if (useCurrentPage) { + fetchBlocklist(); + } else { + gotoBlocklistFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (prevProps.isClearingBlocklistExecuting && !this.props.isClearingBlocklistExecuting) { + this.props.gotoBlocklistFirstPage(); + } + } + + componentWillUnmount() { + this.props.clearBlocklist(); + unregisterPagePopulator(this.repopulate); + } + + // + // Control + + repopulate = () => { + this.props.fetchBlocklist(); + }; + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoBlocklistFirstPage(); + }; + + onPreviousPagePress = () => { + this.props.gotoBlocklistPreviousPage(); + }; + + onNextPagePress = () => { + this.props.gotoBlocklistNextPage(); + }; + + onLastPagePress = () => { + this.props.gotoBlocklistLastPage(); + }; + + onPageSelect = (page) => { + this.props.gotoBlocklistPage({ page }); + }; + + onRemoveSelected = (ids) => { + this.props.removeBlocklistItems({ ids }); + }; + + onSortPress = (sortKey) => { + this.props.setBlocklistSort({ sortKey }); + }; + + onTableOptionChange = (payload) => { + this.props.setBlocklistTableOption(payload); + + if (payload.pageSize) { + this.props.gotoBlocklistFirstPage(); + } + }; + + onClearBlocklistPress = () => { + this.props.executeCommand({ name: commandNames.CLEAR_BLOCKLIST }); + }; + + // + // Render + + render() { + return ( + + ); + } +} + +BlocklistConnector.propTypes = { + useCurrentPage: PropTypes.bool.isRequired, + isClearingBlocklistExecuting: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchBlocklist: PropTypes.func.isRequired, + gotoBlocklistFirstPage: PropTypes.func.isRequired, + gotoBlocklistPreviousPage: PropTypes.func.isRequired, + gotoBlocklistNextPage: PropTypes.func.isRequired, + gotoBlocklistLastPage: PropTypes.func.isRequired, + gotoBlocklistPage: PropTypes.func.isRequired, + removeBlocklistItems: PropTypes.func.isRequired, + setBlocklistSort: PropTypes.func.isRequired, + setBlocklistTableOption: PropTypes.func.isRequired, + clearBlocklist: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(BlocklistConnector) +); diff --git a/frontend/src/Activity/Blocklist/BlocklistDetailsModal.js b/frontend/src/Activity/Blocklist/BlocklistDetailsModal.js new file mode 100644 index 000000000..506fa0129 --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistDetailsModal.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import translate from 'Utilities/String/translate'; + +class BlocklistDetailsModal extends Component { + + // + // Render + + render() { + const { + isOpen, + sourceTitle, + protocol, + indexer, + message, + onModalClose + } = this.props; + + return ( + + + + Details + + + + + + + + + { + !!message && + + } + + { + !!message && + + } + + + + + + + + + ); + } +} + +BlocklistDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + sourceTitle: PropTypes.string.isRequired, + protocol: PropTypes.string.isRequired, + indexer: PropTypes.string, + message: PropTypes.string, + onModalClose: PropTypes.func.isRequired +}; + +export default BlocklistDetailsModal; diff --git a/frontend/src/Activity/Blocklist/BlocklistRow.css b/frontend/src/Activity/Blocklist/BlocklistRow.css new file mode 100644 index 000000000..fe431c64a --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistRow.css @@ -0,0 +1,17 @@ +.quality { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} + +.indexer { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 80px; +} + +.actions { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 70px; +} diff --git a/frontend/src/Activity/Blocklist/BlocklistRow.css.d.ts b/frontend/src/Activity/Blocklist/BlocklistRow.css.d.ts new file mode 100644 index 000000000..a608ab3d2 --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistRow.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'actions': string; + 'indexer': string; + 'quality': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/Blocklist/BlocklistRow.js b/frontend/src/Activity/Blocklist/BlocklistRow.js new file mode 100644 index 000000000..9dcfc47cd --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistRow.js @@ -0,0 +1,200 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import AlbumFormats from 'Album/AlbumFormats'; +import TrackQuality from 'Album/TrackQuality'; +import ArtistNameLink from 'Artist/ArtistNameLink'; +import IconButton from 'Components/Link/IconButton'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import TableRow from 'Components/Table/TableRow'; +import { icons, kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import BlocklistDetailsModal from './BlocklistDetailsModal'; +import styles from './BlocklistRow.css'; + +class BlocklistRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onDetailsPress = () => { + this.setState({ isDetailsModalOpen: true }); + }; + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + }; + + // + // Render + + render() { + const { + id, + artist, + sourceTitle, + quality, + customFormats, + date, + protocol, + indexer, + message, + isSelected, + columns, + onSelectedChange, + onRemovePress + } = this.props; + + if (!artist) { + return null; + } + + return ( + + + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'artists.sortName') { + return ( + + + + ); + } + + if (name === 'sourceTitle') { + return ( + + {sourceTitle} + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'customFormats') { + return ( + + + + ); + } + + if (name === 'date') { + return ( + + ); + } + + if (name === 'indexer') { + return ( + + {indexer} + + ); + } + + if (name === 'actions') { + return ( + + + + + + ); + } + + return null; + }) + } + + + + ); + } + +} + +BlocklistRow.propTypes = { + id: PropTypes.number.isRequired, + artist: PropTypes.object.isRequired, + sourceTitle: PropTypes.string.isRequired, + quality: PropTypes.object.isRequired, + customFormats: PropTypes.arrayOf(PropTypes.object).isRequired, + date: PropTypes.string.isRequired, + protocol: PropTypes.string.isRequired, + indexer: PropTypes.string, + message: PropTypes.string, + isSelected: PropTypes.bool.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onSelectedChange: PropTypes.func.isRequired, + onRemovePress: PropTypes.func.isRequired +}; + +export default BlocklistRow; diff --git a/frontend/src/Activity/Blocklist/BlocklistRowConnector.js b/frontend/src/Activity/Blocklist/BlocklistRowConnector.js new file mode 100644 index 000000000..05b1d8f73 --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistRowConnector.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { removeBlocklistItem } from 'Store/Actions/blocklistActions'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import BlocklistRow from './BlocklistRow'; + +function createMapStateToProps() { + return createSelector( + createArtistSelector(), + (artist) => { + return { + artist + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onRemovePress() { + dispatch(removeBlocklistItem({ id: props.id })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(BlocklistRow); diff --git a/frontend/src/Activity/History/Details/HistoryDetails.css b/frontend/src/Activity/History/Details/HistoryDetails.css new file mode 100644 index 000000000..383f08afd --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetails.css @@ -0,0 +1,5 @@ +.description { + composes: description from '~Components/DescriptionList/DescriptionListItemDescription.css'; + + overflow-wrap: break-word; +} diff --git a/frontend/src/Activity/History/Details/HistoryDetails.css.d.ts b/frontend/src/Activity/History/Details/HistoryDetails.css.d.ts new file mode 100644 index 000000000..ff7055b0f --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetails.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'description': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js index b2adf1171..84aa3e0f2 100644 --- a/frontend/src/Activity/History/Details/HistoryDetails.js +++ b/frontend/src/Activity/History/Details/HistoryDetails.js @@ -1,18 +1,66 @@ import PropTypes from 'prop-types'; import React from 'react'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import formatAge from 'Utilities/Number/formatAge'; -import Link from 'Components/Link/Link'; import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; -import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; +import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import { icons } from 'Helpers/Props'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; +import translate from 'Utilities/String/translate'; +import styles from './HistoryDetails.css'; + +function getDetailedList(statusMessages) { + return ( +
+ { + statusMessages.map(({ title, messages }) => { + return ( +
+ {title} +
    + { + messages.map((message) => { + return ( +
  • + {message} +
  • + ); + }) + } +
+
+ ); + }) + } +
+ ); +} + +function formatMissing(value) { + if (value === undefined || value === 0 || value === '0') { + return (); + } + return value; +} + +function formatChange(oldValue, newValue) { + return ( +
+ {formatMissing(oldValue)} {formatMissing(newValue)} +
+ ); +} function HistoryDetails(props) { const { eventType, sourceTitle, data, + downloadId, shortDateFormat, timeFormat } = props; @@ -21,81 +69,102 @@ function HistoryDetails(props) { const { indexer, releaseGroup, + customFormatScore, nzbInfoUrl, downloadClient, - downloadId, + downloadClientName, age, ageHours, ageMinutes, publishedDate } = data; + const downloadClientNameInfo = downloadClientName ?? downloadClient; + return ( { - !!indexer && + indexer ? + /> : + null } { - !!releaseGroup && + releaseGroup ? + /> : + null } { - !!nzbInfoUrl && + customFormatScore && customFormatScore !== '0' ? + : + null + } + + { + nzbInfoUrl ? - Info URL + {translate('InfoUrl')} {nzbInfoUrl} - + : + null } { - !!downloadClient && + downloadClientNameInfo ? + title={translate('DownloadClient')} + data={downloadClientNameInfo} + /> : + null } { - !!downloadId && + downloadId ? + /> : + null } { - !!indexer && + age || ageHours || ageMinutes ? + /> : + null } { - !!publishedDate && + publishedDate ? + /> : + null } ); @@ -103,29 +172,50 @@ function HistoryDetails(props) { if (eventType === 'downloadFailed') { const { - message + message, + indexer } = data; return ( { - !!message && + downloadId ? : + null + } + + { + indexer ? ( + + ) : null} + + { + message ? + : + null } ); } - if (eventType === 'downloadFolderImported') { + if (eventType === 'trackFileImported') { const { + customFormatScore, droppedPath, importedPath } = data; @@ -133,24 +223,38 @@ function HistoryDetails(props) { return ( { - !!droppedPath && + droppedPath ? + /> : + null } { - !!importedPath && + importedPath ? + /> : + null + } + + { + customFormatScore && customFormatScore !== '0' ? + : + null } ); @@ -158,7 +262,8 @@ function HistoryDetails(props) { if (eventType === 'trackFileDeleted') { const { - reason + reason, + customFormatScore } = data; let reasonMessage = ''; @@ -168,7 +273,7 @@ function HistoryDetails(props) { reasonMessage = 'File was deleted by via UI'; break; case 'MissingFromDisk': - reasonMessage = 'Lidarr was unable to find the file on disk so it was removed'; + reasonMessage = 'Lidarr was unable to find the file on disk so the file was unlinked from the album/track in the database'; break; case 'Upgrade': reasonMessage = 'File was deleted to import an upgrade'; @@ -180,14 +285,23 @@ function HistoryDetails(props) { return ( + + { + customFormatScore && customFormatScore !== '0' ? + : + null + } ); } @@ -195,41 +309,228 @@ function HistoryDetails(props) { if (eventType === 'trackFileRenamed') { const { sourcePath, - sourceRelativePath, - path, - relativePath + path } = data; return ( - - - - ); } + + if (eventType === 'trackFileRetagged') { + const { + diff, + tagsScrubbed + } = data; + + return ( + + + { + JSON.parse(diff).map(({ field, oldValue, newValue }) => { + return ( + + ); + }) + } + : } + /> + + ); + } + + if (eventType === 'albumImportIncomplete') { + const { + statusMessages + } = data; + + return ( + + + + { + !!statusMessages && + + } + + ); + } + + if (eventType === 'downloadImported') { + const { + indexer, + releaseGroup, + customFormatScore, + nzbInfoUrl, + downloadClient, + age, + ageHours, + ageMinutes, + publishedDate + } = data; + + return ( + + + + { + indexer ? + : + null + } + + { + releaseGroup ? + : + null + } + + { + customFormatScore && customFormatScore !== '0' ? + : + null + } + + { + nzbInfoUrl ? + + + {translate('InfoUrl')} + + + + {nzbInfoUrl} + + : + null + } + + { + downloadClient ? + : + null + } + + { + downloadId ? + : + null + } + + { + age || ageHours || ageMinutes ? + : + null + } + + { + publishedDate ? + : + null + } + + ); + } + + if (eventType === 'downloadIgnored') { + const { + message + } = data; + + return ( + + + + { + downloadId ? + : + null + } + + { + message ? + : + null + } + + ); + } + + return ( + + + + ); } HistoryDetails.propTypes = { eventType: PropTypes.string.isRequired, sourceTitle: PropTypes.string.isRequired, data: PropTypes.object.isRequired, + downloadId: PropTypes.string, shortDateFormat: PropTypes.string.isRequired, timeFormat: PropTypes.string.isRequired }; diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.css b/frontend/src/Activity/History/Details/HistoryDetailsModal.css index bdcb7f918..271d422ff 100644 --- a/frontend/src/Activity/History/Details/HistoryDetailsModal.css +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.css @@ -1,5 +1,5 @@ .markAsFailedButton { - composes: button from 'Components/Link/Button.css'; + composes: button from '~Components/Link/Button.css'; margin-right: auto; } diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.css.d.ts b/frontend/src/Activity/History/Details/HistoryDetailsModal.css.d.ts new file mode 100644 index 000000000..a8cc499e2 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'markAsFailedButton': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.js b/frontend/src/Activity/History/Details/HistoryDetailsModal.js index ca8b9ca3a..5362a2f43 100644 --- a/frontend/src/Activity/History/Details/HistoryDetailsModal.js +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.js @@ -1,13 +1,13 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { kinds } from 'Helpers/Props'; import Button from 'Components/Link/Button'; import SpinnerButton from 'Components/Link/SpinnerButton'; import Modal from 'Components/Modal/Modal'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalHeader from 'Components/Modal/ModalHeader'; import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { kinds } from 'Helpers/Props'; import HistoryDetails from './HistoryDetails'; import styles from './HistoryDetailsModal.css'; @@ -17,12 +17,20 @@ function getHeaderTitle(eventType) { return 'Grabbed'; case 'downloadFailed': return 'Download Failed'; - case 'downloadFolderImported': + case 'trackFileImported': return 'Track Imported'; case 'trackFileDeleted': return 'Track File Deleted'; case 'trackFileRenamed': return 'Track File Renamed'; + case 'trackFileRetagged': + return 'Track File Tags Updated'; + case 'albumImportIncomplete': + return 'Album Import Incomplete'; + case 'downloadImported': + return 'Download Completed'; + case 'downloadIgnored': + return 'Download Ignored'; default: return 'Unknown'; } @@ -34,6 +42,7 @@ function HistoryDetailsModal(props) { eventType, sourceTitle, data, + downloadId, isMarkingAsFailed, shortDateFormat, timeFormat, @@ -56,6 +65,7 @@ function HistoryDetailsModal(props) { eventType={eventType} sourceTitle={sourceTitle} data={data} + downloadId={downloadId} shortDateFormat={shortDateFormat} timeFormat={timeFormat} /> @@ -90,6 +100,7 @@ HistoryDetailsModal.propTypes = { eventType: PropTypes.string.isRequired, sourceTitle: PropTypes.string.isRequired, data: PropTypes.object.isRequired, + downloadId: PropTypes.string, isMarkingAsFailed: PropTypes.bool.isRequired, shortDateFormat: PropTypes.string.isRequired, timeFormat: PropTypes.string.isRequired, diff --git a/frontend/src/Activity/History/History.js b/frontend/src/Activity/History/History.js index 8c9548d5d..d144a5402 100644 --- a/frontend/src/Activity/History/History.js +++ b/frontend/src/Activity/History/History.js @@ -1,17 +1,21 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { align, icons } from 'Helpers/Props'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TablePager from 'Components/Table/TablePager'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; -import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; -import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; -import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; -import FilterMenu from 'Components/Menu/FilterMenu'; +import { align, icons, kinds } from 'Helpers/Props'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import translate from 'Utilities/String/translate'; +import HistoryFilterModal from './HistoryFilterModal'; import HistoryRowConnector from './HistoryRowConnector'; class History extends Component { @@ -49,7 +53,10 @@ class History extends Component { columns, selectedFilterKey, filters, + customFilters, totalRecords, + isArtistFetching, + isArtistPopulated, isAlbumsFetching, isAlbumsPopulated, albumsError, @@ -58,16 +65,16 @@ class History extends Component { ...otherProps } = this.props; - const isFetchingAny = isFetching || isAlbumsFetching; - const isAllPopulated = isPopulated && (isAlbumsPopulated || !items.length); + const isFetchingAny = isFetching || isArtistFetching || isAlbumsFetching; + const isAllPopulated = isPopulated && ((isArtistPopulated && isAlbumsPopulated) || !items.length); const hasError = error || albumsError; return ( - + + + + + - + { isFetchingAny && !isAllPopulated && @@ -93,7 +111,9 @@ class History extends Component { { !isFetchingAny && hasError && -
Unable to load history
+ + {translate('UnableToLoadHistory')} + } { @@ -101,9 +121,9 @@ class History extends Component { // wait for the albums to populate because they are never coming. isPopulated && !hasError && !items.length && -
- No history found -
+ + {translate('NoHistory')} + } { @@ -136,7 +156,7 @@ class History extends Component { /> } -
+
); } @@ -148,9 +168,12 @@ History.propTypes = { error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, - selectedFilterKey: PropTypes.string.isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, totalRecords: PropTypes.number, + isArtistFetching: PropTypes.bool.isRequired, + isArtistPopulated: PropTypes.bool.isRequired, isAlbumsFetching: PropTypes.bool.isRequired, isAlbumsPopulated: PropTypes.bool.isRequired, albumsError: PropTypes.object, diff --git a/frontend/src/Activity/History/HistoryConnector.js b/frontend/src/Activity/History/HistoryConnector.js index fd606a5ad..2b3354bc5 100644 --- a/frontend/src/Activity/History/HistoryConnector.js +++ b/frontend/src/Activity/History/HistoryConnector.js @@ -2,27 +2,34 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import withCurrentPage from 'Components/withCurrentPage'; +import { clearAlbums, fetchAlbums } from 'Store/Actions/albumActions'; +import * as historyActions from 'Store/Actions/historyActions'; +import { clearTracks, fetchTracks } from 'Store/Actions/trackActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; -import * as historyActions from 'Store/Actions/historyActions'; -import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions'; -import { fetchTracks, clearTracks } from 'Store/Actions/trackActions'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; import History from './History'; function createMapStateToProps() { return createSelector( (state) => state.history, + (state) => state.artist, (state) => state.albums, (state) => state.tracks, - (history, albums, tracks) => { + createCustomFiltersSelector('history'), + (history, artist, albums, tracks, customFilters) => { return { + isArtistFetching: artist.isFetching, + isArtistPopulated: artist.isPopulated, isAlbumsFetching: albums.isFetching, isAlbumsPopulated: albums.isPopulated, albumsError: albums.error, isTracksFetching: tracks.isFetching, isTracksPopulated: tracks.isPopulated, tracksError: tracks.error, + customFilters, ...history }; } @@ -43,8 +50,19 @@ class HistoryConnector extends Component { // Lifecycle componentDidMount() { + const { + useCurrentPage, + fetchHistory, + gotoHistoryFirstPage + } = this.props; + registerPagePopulator(this.repopulate); - this.props.gotoHistoryFirstPage(); + + if (useCurrentPage) { + fetchHistory(); + } else { + gotoHistoryFirstPage(); + } } componentDidUpdate(prevProps) { @@ -76,38 +94,38 @@ class HistoryConnector extends Component { repopulate = () => { this.props.fetchHistory(); - } + }; // // Listeners onFirstPagePress = () => { this.props.gotoHistoryFirstPage(); - } + }; onPreviousPagePress = () => { this.props.gotoHistoryPreviousPage(); - } + }; onNextPagePress = () => { this.props.gotoHistoryNextPage(); - } + }; onLastPagePress = () => { this.props.gotoHistoryLastPage(); - } + }; onPageSelect = (page) => { this.props.gotoHistoryPage({ page }); - } + }; onSortPress = (sortKey) => { this.props.setHistorySort({ sortKey }); - } + }; onFilterSelect = (selectedFilterKey) => { this.props.setHistoryFilter({ selectedFilterKey }); - } + }; onTableOptionChange = (payload) => { this.props.setHistoryTableOption(payload); @@ -115,7 +133,7 @@ class HistoryConnector extends Component { if (payload.pageSize) { this.props.gotoHistoryFirstPage(); } - } + }; // // Render @@ -138,6 +156,7 @@ class HistoryConnector extends Component { } HistoryConnector.propTypes = { + useCurrentPage: PropTypes.bool.isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired, fetchHistory: PropTypes.func.isRequired, gotoHistoryFirstPage: PropTypes.func.isRequired, @@ -155,4 +174,6 @@ HistoryConnector.propTypes = { clearTracks: PropTypes.func.isRequired }; -export default connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector); +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector) +); diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.css b/frontend/src/Activity/History/HistoryEventTypeCell.css index 086354783..63d79e18c 100644 --- a/frontend/src/Activity/History/HistoryEventTypeCell.css +++ b/frontend/src/Activity/History/HistoryEventTypeCell.css @@ -1,3 +1,6 @@ .cell { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + width: 35px; + text-align: center; } diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.css.d.ts b/frontend/src/Activity/History/HistoryEventTypeCell.css.d.ts new file mode 100644 index 000000000..c748f6f97 --- /dev/null +++ b/frontend/src/Activity/History/HistoryEventTypeCell.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'cell': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.js b/frontend/src/Activity/History/HistoryEventTypeCell.js index 065ab0492..937cedd98 100644 --- a/frontend/src/Activity/History/HistoryEventTypeCell.js +++ b/frontend/src/Activity/History/HistoryEventTypeCell.js @@ -1,24 +1,33 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { icons, kinds } from 'Helpers/Props'; import Icon from 'Components/Icon'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import { icons, kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; import styles from './HistoryEventTypeCell.css'; -function getIconName(eventType) { +function getIconName(eventType, data) { switch (eventType) { case 'grabbed': return icons.DOWNLOADING; case 'artistFolderImported': return icons.DRIVE; - case 'downloadFolderImported': + case 'trackFileImported': return icons.DOWNLOADED; case 'downloadFailed': return icons.DOWNLOADING; case 'trackFileDeleted': - return icons.DELETE; + return data.reason === 'MissingFromDisk' ? icons.FILE_MISSING : icons.DELETE; case 'trackFileRenamed': return icons.ORGANIZE; + case 'trackFileRetagged': + return icons.RETAG; + case 'albumImportIncomplete': + return icons.DOWNLOADED; + case 'downloadImported': + return icons.DOWNLOADED; + case 'downloadIgnored': + return icons.IGNORE; default: return icons.UNKNOWN; } @@ -28,6 +37,8 @@ function getIconKind(eventType) { switch (eventType) { case 'downloadFailed': return kinds.DANGER; + case 'albumImportIncomplete': + return kinds.WARNING; default: return kinds.DEFAULT; } @@ -39,21 +50,29 @@ function getTooltip(eventType, data) { return `Album grabbed from ${data.indexer} and sent to ${data.downloadClient}`; case 'artistFolderImported': return 'Track imported from artist folder'; - case 'downloadFolderImported': + case 'trackFileImported': return 'Track downloaded successfully and picked up from download client'; case 'downloadFailed': return 'Album download failed'; case 'trackFileDeleted': - return 'Track file deleted'; + return data.reason === 'MissingFromDisk' ? translate('TrackFileMissingTooltip') : translate('TrackFileDeletedTooltip'); case 'trackFileRenamed': - return 'Track file renamed'; + return translate('TrackFileRenamedTooltip'); + case 'trackFileRetagged': + return translate('TrackFileTagsUpdatedTooltip'); + case 'albumImportIncomplete': + return 'Files downloaded but not all could be imported'; + case 'downloadImported': + return 'Download completed and successfully imported'; + case 'downloadIgnored': + return 'Album Download Ignored'; default: return 'Unknown event'; } } function HistoryEventTypeCell({ eventType, data }) { - const iconName = getIconName(eventType); + const iconName = getIconName(eventType, data); const iconKind = getIconKind(eventType); const tooltip = getTooltip(eventType, data); diff --git a/frontend/src/Activity/History/HistoryFilterModal.tsx b/frontend/src/Activity/History/HistoryFilterModal.tsx new file mode 100644 index 000000000..f4ad2e57c --- /dev/null +++ b/frontend/src/Activity/History/HistoryFilterModal.tsx @@ -0,0 +1,54 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setHistoryFilter } from 'Store/Actions/historyActions'; + +function createHistorySelector() { + return createSelector( + (state: AppState) => state.history.items, + (queueItems) => { + return queueItems; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.history.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface HistoryFilterModalProps { + isOpen: boolean; +} + +export default function HistoryFilterModal(props: HistoryFilterModalProps) { + const sectionItems = useSelector(createHistorySelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'history'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setHistoryFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Activity/History/HistoryRow.css b/frontend/src/Activity/History/HistoryRow.css index 83586af58..039804b63 100644 --- a/frontend/src/Activity/History/HistoryRow.css +++ b/frontend/src/Activity/History/HistoryRow.css @@ -1,23 +1,29 @@ .downloadClient { - composes: cell from 'Components/Table/Cells/TableRowCell.css'; + composes: cell from '~Components/Table/Cells/TableRowCell.css'; width: 120px; } .indexer { - composes: cell from 'Components/Table/Cells/TableRowCell.css'; + composes: cell from '~Components/Table/Cells/TableRowCell.css'; width: 80px; } +.customFormatScore { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 55px; +} + .releaseGroup { - composes: cell from 'Components/Table/Cells/TableRowCell.css'; + composes: cell from '~Components/Table/Cells/TableRowCell.css'; width: 110px; } .details { - composes: cell from 'Components/Table/Cells/TableRowCell.css'; + composes: cell from '~Components/Table/Cells/TableRowCell.css'; width: 30px; } diff --git a/frontend/src/Activity/History/HistoryRow.css.d.ts b/frontend/src/Activity/History/HistoryRow.css.d.ts new file mode 100644 index 000000000..e1f54bc96 --- /dev/null +++ b/frontend/src/Activity/History/HistoryRow.css.d.ts @@ -0,0 +1,11 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'customFormatScore': string; + 'details': string; + 'downloadClient': string; + 'indexer': string; + 'releaseGroup': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/History/HistoryRow.js b/frontend/src/Activity/History/HistoryRow.js index 715851b7e..9f2da78d0 100644 --- a/frontend/src/Activity/History/HistoryRow.js +++ b/frontend/src/Activity/History/HistoryRow.js @@ -1,16 +1,18 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { icons } from 'Helpers/Props'; +import AlbumFormats from 'Album/AlbumFormats'; +import AlbumTitleLink from 'Album/AlbumTitleLink'; +import TrackQuality from 'Album/TrackQuality'; +import ArtistNameLink from 'Artist/ArtistNameLink'; import IconButton from 'Components/Link/IconButton'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; -import TableRow from 'Components/Table/TableRow'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import AlbumTitleLink from 'Album/AlbumTitleLink'; -import EpisodeLanguage from 'Album/EpisodeLanguage'; -import EpisodeQuality from 'Album/EpisodeQuality'; -import ArtistNameLink from 'Artist/ArtistNameLink'; -import HistoryEventTypeCell from './HistoryEventTypeCell'; +import TableRow from 'Components/Table/TableRow'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import { icons, tooltipPositions } from 'Helpers/Props'; +import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; import HistoryDetailsModal from './Details/HistoryDetailsModal'; +import HistoryEventTypeCell from './HistoryEventTypeCell'; import styles from './HistoryRow.css'; class HistoryRow extends Component { @@ -41,11 +43,11 @@ class HistoryRow extends Component { onDetailsPress = () => { this.setState({ isDetailsModalOpen: true }); - } + }; onDetailsModalClose = () => { this.setState({ isDetailsModalOpen: false }); - } + }; // // Render @@ -55,14 +57,15 @@ class HistoryRow extends Component { artist, album, track, - language, - languageCutoffNotMet, quality, + customFormats, + customFormatScore, qualityCutoffNotMet, eventType, sourceTitle, date, data, + downloadId, isMarkingAsFailed, columns, shortDateFormat, @@ -70,7 +73,7 @@ class HistoryRow extends Component { onMarkAsFailedPress } = this.props; - if (!album) { + if (!artist || !album) { return null; } @@ -97,7 +100,7 @@ class HistoryRow extends Component { ); } - if (name === 'artist.sortName') { + if (name === 'artists.sortName') { return ( ); @@ -127,23 +131,22 @@ class HistoryRow extends Component { ); } - if (name === 'language') { + if (name === 'quality') { return ( - ); } - if (name === 'quality') { + if (name === 'customFormats') { return ( - ); @@ -180,6 +183,24 @@ class HistoryRow extends Component { ); } + if (name === 'customFormatScore') { + return ( + + } + position={tooltipPositions.BOTTOM} + /> + + ); + } + if (name === 'releaseGroup') { return ( + {sourceTitle} + + ); + } + if (name === 'details') { return ( { this.props.markAsFailed({ id: this.props.id }); - } + }; // // Render diff --git a/frontend/src/Activity/Queue/ProtocolLabel.css b/frontend/src/Activity/Queue/ProtocolLabel.css index 15e8e4fc6..110c7e01c 100644 --- a/frontend/src/Activity/Queue/ProtocolLabel.css +++ b/frontend/src/Activity/Queue/ProtocolLabel.css @@ -1,13 +1,13 @@ .torrent { - composes: label from 'Components/Label.css'; + composes: label from '~Components/Label.css'; - border-color: $torrentColor; - background-color: $torrentColor; + border-color: var(--torrentColor); + background-color: var(--torrentColor); } .usenet { - composes: label from 'Components/Label.css'; + composes: label from '~Components/Label.css'; - border-color: $usenetColor; - background-color: $usenetColor; + border-color: var(--usenetColor); + background-color: var(--usenetColor); } diff --git a/frontend/src/Activity/Queue/ProtocolLabel.css.d.ts b/frontend/src/Activity/Queue/ProtocolLabel.css.d.ts new file mode 100644 index 000000000..f3b389e3d --- /dev/null +++ b/frontend/src/Activity/Queue/ProtocolLabel.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'torrent': string; + 'usenet': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/Queue/Queue.js b/frontend/src/Activity/Queue/Queue.js index 0392f7ebb..0efc29f21 100644 --- a/frontend/src/Activity/Queue/Queue.js +++ b/frontend/src/Activity/Queue/Queue.js @@ -1,23 +1,31 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import getSelectedIds from 'Utilities/Table/getSelectedIds'; -import selectAll from 'Utilities/Table/selectAll'; -import toggleSelected from 'Utilities/Table/toggleSelected'; -import { icons } from 'Helpers/Props'; +import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TablePager from 'Components/Table/TablePager'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; -import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; -import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; -import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; -import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; -import RemoveQueueItemsModal from './RemoveQueueItemsModal'; +import { align, icons, kinds } from 'Helpers/Props'; +import getRemovedItems from 'Utilities/Object/getRemovedItems'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import QueueFilterModal from './QueueFilterModal'; +import QueueOptionsConnector from './QueueOptionsConnector'; import QueueRowConnector from './QueueRowConnector'; +import RemoveQueueItemModal from './RemoveQueueItemModal'; class Queue extends Component { @@ -27,28 +35,21 @@ class Queue extends Component { constructor(props, context) { super(props, context); + this._shouldBlockRefresh = false; + this.state = { allSelected: false, allUnselected: false, lastToggled: null, selectedState: {}, isPendingSelected: false, - isConfirmRemoveModalOpen: false + isConfirmRemoveModalOpen: false, + items: props.items }; } - shouldComponentUpdate(nextProps) { - // Don't update when fetching has completed if items have changed, - // before albums start fetching or when albums start fetching. - - if ( - ( - this.props.isFetching && - nextProps.isPopulated && - hasDifferentItems(this.props.items, nextProps.items) - ) || - (!this.props.isAlbumsFetching && nextProps.isAlbumsFetching) - ) { + shouldComponentUpdate() { + if (this._shouldBlockRefresh) { return false; } @@ -56,18 +57,44 @@ class Queue extends Component { } componentDidUpdate(prevProps) { - if (hasDifferentItems(prevProps.items, this.props.items)) { - this.setState({ selectedState: {} }); + const { + items, + isFetching, + isAlbumsFetching + } = this.props; + + if ( + (!isAlbumsFetching && prevProps.isAlbumsFetching) || + (!isFetching && prevProps.isFetching) || + (hasDifferentItems(prevProps.items, items) && !items.some((e) => e.albumId)) + ) { + this.setState((state) => { + return { + ...removeOldSelectedState(state, getRemovedItems(prevProps.items, items)), + items + }; + }); + return; } + const nextState = {}; + + if (prevProps.items !== items) { + nextState.items = items; + } + const selectedIds = this.getSelectedIds(); const isPendingSelected = _.some(this.props.items, (item) => { - return selectedIds.indexOf(item.id) > -1 && item.status === 'Delay'; + return selectedIds.indexOf(item.id) > -1 && item.status === 'delay'; }); if (isPendingSelected !== this.state.isPendingSelected) { - this.setState({ isPendingSelected }); + nextState.isPendingSelected = isPendingSelected; + } + + if (!_.isEmpty(nextState)) { + this.setState(nextState); } } @@ -76,37 +103,45 @@ class Queue extends Component { getSelectedIds = () => { return getSelectedIds(this.state.selectedState); - } + }; // // Listeners + onQueueRowModalOpenOrClose = (isOpen) => { + this._shouldBlockRefresh = isOpen; + }; + onSelectAllChange = ({ value }) => { this.setState(selectAll(this.state.selectedState, value)); - } + }; onSelectedChange = ({ id, value, shiftKey = false }) => { this.setState((state) => { return toggleSelected(state, this.props.items, id, value, shiftKey); }); - } + }; onGrabSelectedPress = () => { this.props.onGrabSelectedPress(this.getSelectedIds()); - } + }; onRemoveSelectedPress = () => { - this.setState({ isConfirmRemoveModalOpen: true }); - } + this.setState({ isConfirmRemoveModalOpen: true }, () => { + this._shouldBlockRefresh = true; + }); + }; - onRemoveSelectedConfirmed = (blacklist) => { - this.props.onRemoveSelectedPress(this.getSelectedIds(), blacklist); + onRemoveSelectedConfirmed = (payload) => { + this._shouldBlockRefresh = false; + this.props.onRemoveSelectedPress({ ids: this.getSelectedIds(), ...payload }); this.setState({ isConfirmRemoveModalOpen: false }); - } + }; onConfirmRemoveModalClose = () => { + this._shouldBlockRefresh = false; this.setState({ isConfirmRemoveModalOpen: false }); - } + }; // // Render @@ -116,16 +151,22 @@ class Queue extends Component { isFetching, isPopulated, error, - items, + isArtistFetching, + isArtistPopulated, isAlbumsFetching, isAlbumsPopulated, albumsError, columns, + selectedFilterKey, + filters, + customFilters, + count, totalRecords, isGrabbing, isRemoving, - isCheckForFinishedDownloadExecuting, + isRefreshMonitoredDownloadsExecuting, onRefreshPress, + onFilterSelect, ...otherProps } = this.props; @@ -134,21 +175,23 @@ class Queue extends Component { allUnselected, selectedState, isConfirmRemoveModalOpen, - isPendingSelected + isPendingSelected, + items } = this.state; - const isRefreshing = isFetching || isAlbumsFetching || isCheckForFinishedDownloadExecuting; - const isAllPopulated = isPopulated && (isAlbumsPopulated || !items.length); + const isRefreshing = isFetching || isArtistFetching || isAlbumsFetching || isRefreshMonitoredDownloadsExecuting; + const isAllPopulated = isPopulated && ((isArtistPopulated && isAlbumsPopulated) || !items.length || items.every((e) => !e.albumId)); const hasError = error || albumsError; - const selectedCount = this.getSelectedIds().length; + const selectedIds = this.getSelectedIds(); + const selectedCount = selectedIds.length; const disableSelectedActions = selectedCount === 0; return ( - + + + + + + + + + - + { - isRefreshing && !isAllPopulated && - + isRefreshing && !isAllPopulated ? + : + null } { - !isRefreshing && hasError && -
- Failed to load Queue -
+ !isRefreshing && hasError ? + + {translate('FailedToLoadQueue')} + : + null } { - isPopulated && !hasError && !items.length && -
- Queue is empty -
+ isAllPopulated && !hasError && !items.length ? + + { + selectedFilterKey !== 'all' && count > 0 ? + translate('QueueFilterHasNoItems') : + translate('QueueIsEmpty') + } + : + null } { - isAllPopulated && !hasError && !!items.length && + isAllPopulated && !hasError && !!items.length ?
@@ -216,6 +291,7 @@ class Queue extends Component { columns={columns} {...item} onSelectedChange={this.onSelectedChange} + onQueueRowModalOpenOrClose={this.onQueueRowModalOpenOrClose} /> ); }) @@ -228,13 +304,39 @@ class Queue extends Component { isFetching={isRefreshing} {...otherProps} /> - + : + null } - + - { + const item = items.find((i) => i.id === id); + + return !!(item && item.downloadClientHasPostImportCategory); + }) + )} + canIgnore={isConfirmRemoveModalOpen && ( + selectedIds.every((id) => { + const item = items.find((i) => i.id === id); + + return !!(item && item.artistId && item.albumId); + }) + )} + pending={isConfirmRemoveModalOpen && ( + selectedIds.every((id) => { + const item = items.find((i) => i.id === id); + + if (!item) { + return false; + } + + return item.status === 'delay' || item.status === 'downloadClientUnavailable'; + }) + )} onRemovePress={this.onRemoveSelectedConfirmed} onModalClose={this.onConfirmRemoveModalClose} /> @@ -248,17 +350,28 @@ Queue.propTypes = { isPopulated: PropTypes.bool.isRequired, error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, + isArtistFetching: PropTypes.bool.isRequired, + isArtistPopulated: PropTypes.bool.isRequired, isAlbumsFetching: PropTypes.bool.isRequired, isAlbumsPopulated: PropTypes.bool.isRequired, albumsError: PropTypes.object, columns: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + count: PropTypes.number.isRequired, totalRecords: PropTypes.number, isGrabbing: PropTypes.bool.isRequired, isRemoving: PropTypes.bool.isRequired, - isCheckForFinishedDownloadExecuting: PropTypes.bool.isRequired, + isRefreshMonitoredDownloadsExecuting: PropTypes.bool.isRequired, onRefreshPress: PropTypes.func.isRequired, onGrabSelectedPress: PropTypes.func.isRequired, - onRemoveSelectedPress: PropTypes.func.isRequired + onRemoveSelectedPress: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired +}; + +Queue.defaultProps = { + count: 0 }; export default Queue; diff --git a/frontend/src/Activity/Queue/QueueConnector.js b/frontend/src/Activity/Queue/QueueConnector.js index d9de161f2..fc0bb4699 100644 --- a/frontend/src/Activity/Queue/QueueConnector.js +++ b/frontend/src/Activity/Queue/QueueConnector.js @@ -1,31 +1,39 @@ -import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; -import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import * as commandNames from 'Commands/commandNames'; +import withCurrentPage from 'Components/withCurrentPage'; +import { clearAlbums, fetchAlbums } from 'Store/Actions/albumActions'; import { executeCommand } from 'Store/Actions/commandActions'; import * as queueActions from 'Store/Actions/queueActions'; -import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions'; -import * as commandNames from 'Commands/commandNames'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; import Queue from './Queue'; function createMapStateToProps() { return createSelector( + (state) => state.artist, (state) => state.albums, + (state) => state.queue.options, (state) => state.queue.paged, - createCommandsSelector(), - (albums, queue, commands) => { - const isCheckForFinishedDownloadExecuting = _.some(commands, { name: commandNames.CHECK_FOR_FINISHED_DOWNLOAD }); - + (state) => state.queue.status.item, + createCustomFiltersSelector('queue'), + createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS), + (artist, albums, options, queue, status, customFilters, isRefreshMonitoredDownloadsExecuting) => { return { + count: options.includeUnknownArtistItems ? status.totalCount : status.count, + isArtistFetching: artist.isFetching, + isArtistPopulated: artist.isPopulated, isAlbumsFetching: albums.isFetching, isAlbumsPopulated: albums.isPopulated, albumsError: albums.error, - isCheckForFinishedDownloadExecuting, + customFilters, + isRefreshMonitoredDownloadsExecuting, + ...options, ...queue }; } @@ -45,19 +53,40 @@ class QueueConnector extends Component { // Lifecycle componentDidMount() { + const { + useCurrentPage, + fetchQueue, + fetchQueueStatus, + gotoQueueFirstPage + } = this.props; + registerPagePopulator(this.repopulate); - this.props.gotoQueueFirstPage(); + + if (useCurrentPage) { + fetchQueue(); + } else { + gotoQueueFirstPage(); + } + + fetchQueueStatus(); } componentDidUpdate(prevProps) { if (hasDifferentItems(prevProps.items, this.props.items)) { const albumIds = selectUniqueIds(this.props.items, 'albumId'); + if (albumIds.length) { this.props.fetchAlbums({ albumIds }); } else { this.props.clearAlbums(); } + } + if ( + this.props.includeUnknownArtistItems !== + prevProps.includeUnknownArtistItems + ) { + this.repopulate(); } } @@ -72,34 +101,38 @@ class QueueConnector extends Component { repopulate = () => { this.props.fetchQueue(); - } + }; // // Listeners onFirstPagePress = () => { this.props.gotoQueueFirstPage(); - } + }; onPreviousPagePress = () => { this.props.gotoQueuePreviousPage(); - } + }; onNextPagePress = () => { this.props.gotoQueueNextPage(); - } + }; onLastPagePress = () => { this.props.gotoQueueLastPage(); - } + }; onPageSelect = (page) => { this.props.gotoQueuePage({ page }); - } + }; onSortPress = (sortKey) => { this.props.setQueueSort({ sortKey }); - } + }; + + onFilterSelect = (selectedFilterKey) => { + this.props.setQueueFilter({ selectedFilterKey }); + }; onTableOptionChange = (payload) => { this.props.setQueueTableOption(payload); @@ -107,21 +140,21 @@ class QueueConnector extends Component { if (payload.pageSize) { this.props.gotoQueueFirstPage(); } - } + }; onRefreshPress = () => { this.props.executeCommand({ - name: commandNames.CHECK_FOR_FINISHED_DOWNLOAD + name: commandNames.REFRESH_MONITORED_DOWNLOADS }); - } + }; onGrabSelectedPress = (ids) => { this.props.grabQueueItems({ ids }); - } + }; - onRemoveSelectedPress = (ids, blacklist) => { - this.props.removeQueueItems({ ids, blacklist }); - } + onRemoveSelectedPress = (payload) => { + this.props.removeQueueItems(payload); + }; // // Render @@ -135,6 +168,7 @@ class QueueConnector extends Component { onLastPagePress={this.onLastPagePress} onPageSelect={this.onPageSelect} onSortPress={this.onSortPress} + onFilterSelect={this.onFilterSelect} onTableOptionChange={this.onTableOptionChange} onRefreshPress={this.onRefreshPress} onGrabSelectedPress={this.onGrabSelectedPress} @@ -146,14 +180,18 @@ class QueueConnector extends Component { } QueueConnector.propTypes = { + useCurrentPage: PropTypes.bool.isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired, + includeUnknownArtistItems: PropTypes.bool.isRequired, fetchQueue: PropTypes.func.isRequired, + fetchQueueStatus: PropTypes.func.isRequired, gotoQueueFirstPage: PropTypes.func.isRequired, gotoQueuePreviousPage: PropTypes.func.isRequired, gotoQueueNextPage: PropTypes.func.isRequired, gotoQueueLastPage: PropTypes.func.isRequired, gotoQueuePage: PropTypes.func.isRequired, setQueueSort: PropTypes.func.isRequired, + setQueueFilter: PropTypes.func.isRequired, setQueueTableOption: PropTypes.func.isRequired, clearQueue: PropTypes.func.isRequired, grabQueueItems: PropTypes.func.isRequired, @@ -163,4 +201,6 @@ QueueConnector.propTypes = { executeCommand: PropTypes.func.isRequired }; -export default connect(createMapStateToProps, mapDispatchToProps)(QueueConnector); +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(QueueConnector) +); diff --git a/frontend/src/Activity/Queue/QueueDetails.js b/frontend/src/Activity/Queue/QueueDetails.js index 8256b8af3..208f50f4c 100644 --- a/frontend/src/Activity/Queue/QueueDetails.js +++ b/frontend/src/Activity/Queue/QueueDetails.js @@ -1,8 +1,9 @@ import moment from 'moment'; import PropTypes from 'prop-types'; import React from 'react'; -import { icons, kinds } from 'Helpers/Props'; import Icon from 'Components/Icon'; +import { icons, kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; function QueueDetails(props) { const { @@ -10,20 +11,20 @@ function QueueDetails(props) { size, sizeleft, estimatedCompletionTime, - status: queueStatus, + status, + trackedDownloadState, + trackedDownloadStatus, errorMessage, progressBar } = props; - const status = queueStatus.toLowerCase(); - const progress = (100 - sizeleft / size * 100); if (status === 'pending') { return ( ); } @@ -34,12 +35,40 @@ function QueueDetails(props) { ); } - // TODO: show an icon when download is complete, but not imported yet? + if (trackedDownloadStatus === 'warning') { + return ( + + ); + } + + if (trackedDownloadState === 'importPending') { + return ( + + ); + } + + if (trackedDownloadState === 'importing') { + return ( + + ); + } } if (errorMessage) { @@ -47,7 +76,7 @@ function QueueDetails(props) { ); } @@ -57,7 +86,7 @@ function QueueDetails(props) { ); } @@ -67,7 +96,7 @@ function QueueDetails(props) { ); } @@ -76,7 +105,7 @@ function QueueDetails(props) { return ( ); } @@ -90,6 +119,8 @@ QueueDetails.propTypes = { sizeleft: PropTypes.number.isRequired, estimatedCompletionTime: PropTypes.string, status: PropTypes.string.isRequired, + trackedDownloadState: PropTypes.string.isRequired, + trackedDownloadStatus: PropTypes.string.isRequired, errorMessage: PropTypes.string, progressBar: PropTypes.node.isRequired }; diff --git a/frontend/src/Activity/Queue/QueueFilterModal.tsx b/frontend/src/Activity/Queue/QueueFilterModal.tsx new file mode 100644 index 000000000..3fce6c166 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueFilterModal.tsx @@ -0,0 +1,54 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setQueueFilter } from 'Store/Actions/queueActions'; + +function createQueueSelector() { + return createSelector( + (state: AppState) => state.queue.paged.items, + (queueItems) => { + return queueItems; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.queue.paged.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface QueueFilterModalProps { + isOpen: boolean; +} + +export default function QueueFilterModal(props: QueueFilterModalProps) { + const sectionItems = useSelector(createQueueSelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'queue'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setQueueFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Activity/Queue/QueueOptions.js b/frontend/src/Activity/Queue/QueueOptions.js new file mode 100644 index 000000000..2ca0d7663 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptions.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import { inputTypes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; + +class QueueOptions extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + includeUnknownArtistItems: props.includeUnknownArtistItems + }; + } + + componentDidUpdate(prevProps) { + const { + includeUnknownArtistItems + } = this.props; + + if (includeUnknownArtistItems !== prevProps.includeUnknownArtistItems) { + this.setState({ + includeUnknownArtistItems + }); + } + } + + // + // Listeners + + onOptionChange = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onOptionChange({ + [name]: value + }); + }); + }; + + // + // Render + + render() { + const { + includeUnknownArtistItems + } = this.state; + + return ( + + + + {translate('ShowUnknownArtistItems')} + + + + + + ); + } +} + +QueueOptions.propTypes = { + includeUnknownArtistItems: PropTypes.bool.isRequired, + onOptionChange: PropTypes.func.isRequired +}; + +export default QueueOptions; diff --git a/frontend/src/Activity/Queue/QueueOptionsConnector.js b/frontend/src/Activity/Queue/QueueOptionsConnector.js new file mode 100644 index 000000000..b2c99511c --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptionsConnector.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setQueueOption } from 'Store/Actions/queueActions'; +import QueueOptions from './QueueOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.queue.options, + (options) => { + return options; + } + ); +} + +const mapDispatchToProps = { + onOptionChange: setQueueOption +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueueOptions); diff --git a/frontend/src/Activity/Queue/QueueRow.css b/frontend/src/Activity/Queue/QueueRow.css index 6aa4a1622..2a0df3595 100644 --- a/frontend/src/Activity/Queue/QueueRow.css +++ b/frontend/src/Activity/Queue/QueueRow.css @@ -1,23 +1,30 @@ .quality { - composes: cell from 'Components/Table/Cells/TableRowCell.css'; + composes: cell from '~Components/Table/Cells/TableRowCell.css'; width: 150px; } .protocol { - composes: cell from 'Components/Table/Cells/TableRowCell.css'; + composes: cell from '~Components/Table/Cells/TableRowCell.css'; width: 100px; } .progress { - composes: cell from 'Components/Table/Cells/TableRowCell.css'; + composes: cell from '~Components/Table/Cells/TableRowCell.css'; width: 150px; } -.actions { - composes: cell from 'Components/Table/Cells/TableRowCell.css'; +.customFormatScore { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; - width: 70px; + width: 55px; +} + +.actions { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 90px; + text-align: right; } diff --git a/frontend/src/Activity/Queue/QueueRow.css.d.ts b/frontend/src/Activity/Queue/QueueRow.css.d.ts new file mode 100644 index 000000000..13d67ea3a --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRow.css.d.ts @@ -0,0 +1,11 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'actions': string; + 'customFormatScore': string; + 'progress': string; + 'protocol': string; + 'quality': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js index 5d5e9dbd2..d0f1fbacf 100644 --- a/frontend/src/Activity/Queue/QueueRow.js +++ b/frontend/src/Activity/Queue/QueueRow.js @@ -1,20 +1,28 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { icons, kinds } from 'Helpers/Props'; +import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; +import AlbumFormats from 'Album/AlbumFormats'; +import AlbumTitleLink from 'Album/AlbumTitleLink'; +import TrackQuality from 'Album/TrackQuality'; +import ArtistNameLink from 'Artist/ArtistNameLink'; +import Icon from 'Components/Icon'; import IconButton from 'Components/Link/IconButton'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import ProgressBar from 'Components/ProgressBar'; -import TableRow from 'Components/Table/TableRow'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; -import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; -import AlbumTitleLink from 'Album/AlbumTitleLink'; -import EpisodeQuality from 'Album/EpisodeQuality'; +import TableRow from 'Components/Table/TableRow'; +import Popover from 'Components/Tooltip/Popover'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; -import ArtistNameLink from 'Artist/ArtistNameLink'; +import formatBytes from 'Utilities/Number/formatBytes'; +import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; +import translate from 'Utilities/String/translate'; import QueueStatusCell from './QueueStatusCell'; -import TimeleftCell from './TimeleftCell'; import RemoveQueueItemModal from './RemoveQueueItemModal'; +import TimeleftCell from './TimeleftCell'; import styles from './QueueRow.css'; class QueueRow extends Component { @@ -36,24 +44,37 @@ class QueueRow extends Component { onRemoveQueueItemPress = () => { this.setState({ isRemoveQueueItemModalOpen: true }); - } + }; + + onRemoveQueueItemModalConfirmed = (blocklist, skipRedownload) => { + const { + onRemoveQueueItemPress, + onQueueRowModalOpenOrClose + } = this.props; + + onQueueRowModalOpenOrClose(false); + onRemoveQueueItemPress(blocklist, skipRedownload); - onRemoveQueueItemModalConfirmed = (blacklist) => { - this.props.onRemoveQueueItemPress(blacklist); this.setState({ isRemoveQueueItemModalOpen: false }); - } + }; onRemoveQueueItemModalClose = () => { + this.props.onQueueRowModalOpenOrClose(false); + this.setState({ isRemoveQueueItemModalOpen: false }); - } + }; onInteractiveImportPress = () => { + this.props.onQueueRowModalOpenOrClose(true); + this.setState({ isInteractiveImportModalOpen: true }); - } + }; onInteractiveImportModalClose = () => { + this.props.onQueueRowModalOpenOrClose(false); + this.setState({ isInteractiveImportModalOpen: false }); - } + }; // // Render @@ -65,15 +86,22 @@ class QueueRow extends Component { title, status, trackedDownloadStatus, + trackedDownloadState, statusMessages, errorMessage, artist, album, quality, + customFormats, + customFormatScore, protocol, indexer, + outputPath, downloadClient, + downloadClientHasPostImportCategory, + downloadForced, estimatedCompletionTime, + added, timeleft, size, sizeleft, @@ -95,8 +123,8 @@ class QueueRow extends Component { } = this.state; const progress = 100 - (sizeleft / size * 100); - const showInteractiveImport = status === 'Completed' && trackedDownloadStatus === 'Warning'; - const isPending = status === 'Delay' || status === 'DownloadClientUnavailable'; + const showInteractiveImport = status === 'completed' && trackedDownloadStatus === 'warning'; + const isPending = status === 'delay' || status === 'downloadClientUnavailable'; return ( @@ -124,41 +152,57 @@ class QueueRow extends Component { sourceTitle={title} status={status} trackedDownloadStatus={trackedDownloadStatus} + trackedDownloadState={trackedDownloadState} statusMessages={statusMessages} errorMessage={errorMessage} /> ); } - if (name === 'artist.sortName') { + if (name === 'artists.sortName') { return ( - + { + artist ? + : + title + } ); } - if (name === 'artist') { + if (name === 'albums.title') { return ( - + { + album ? + : + '-' + } ); } - if (name === 'album.title') { + if (name === 'albums.releaseDate') { + if (album) { + return ( + + ); + } + return ( - + - ); } @@ -166,8 +210,40 @@ class QueueRow extends Component { if (name === 'quality') { return ( - : + null + } + + ); + } + + if (name === 'customFormats') { + return ( + + + + ); + } + + if (name === 'customFormatScore') { + return ( + + } + position={tooltipPositions.BOTTOM} /> ); @@ -199,6 +275,28 @@ class QueueRow extends Component { ); } + if (name === 'title') { + return ( + + {title} + + ); + } + + if (name === 'size') { + return ( + {formatBytes(size)} + ); + } + + if (name === 'outputPath') { + return ( + + {outputPath} + + ); + } + if (name === 'estimatedCompletionTime') { return ( + ); + } + if (name === 'actions') { return ( + { + downloadForced && + + } + title={translate('ManualDownload')} + body="This release failed parsing checks and was manually downloaded from an interactive search. Import is likely to fail." + position={tooltipPositions.LEFT} + /> + } + { showInteractiveImport && @@ -294,15 +422,22 @@ QueueRow.propTypes = { title: PropTypes.string.isRequired, status: PropTypes.string.isRequired, trackedDownloadStatus: PropTypes.string, + trackedDownloadState: PropTypes.string, statusMessages: PropTypes.arrayOf(PropTypes.object), errorMessage: PropTypes.string, - artist: PropTypes.object.isRequired, - album: PropTypes.object.isRequired, + artist: PropTypes.object, + album: PropTypes.object, quality: PropTypes.object.isRequired, + customFormats: PropTypes.arrayOf(PropTypes.object), + customFormatScore: PropTypes.number.isRequired, protocol: PropTypes.string.isRequired, indexer: PropTypes.string, + outputPath: PropTypes.string, downloadClient: PropTypes.string, + downloadClientHasPostImportCategory: PropTypes.bool, + downloadForced: PropTypes.bool.isRequired, estimatedCompletionTime: PropTypes.string, + added: PropTypes.string, timeleft: PropTypes.string, size: PropTypes.number, sizeleft: PropTypes.number, @@ -316,10 +451,12 @@ QueueRow.propTypes = { columns: PropTypes.arrayOf(PropTypes.object).isRequired, onSelectedChange: PropTypes.func.isRequired, onGrabPress: PropTypes.func.isRequired, - onRemoveQueueItemPress: PropTypes.func.isRequired + onRemoveQueueItemPress: PropTypes.func.isRequired, + onQueueRowModalOpenOrClose: PropTypes.func.isRequired }; QueueRow.defaultProps = { + customFormats: [], isGrabbing: false, isRemoving: false }; diff --git a/frontend/src/Activity/Queue/QueueRowConnector.js b/frontend/src/Activity/Queue/QueueRowConnector.js index 3c89a868b..e2a5ba368 100644 --- a/frontend/src/Activity/Queue/QueueRowConnector.js +++ b/frontend/src/Activity/Queue/QueueRowConnector.js @@ -1,11 +1,10 @@ -import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions'; -import createArtistSelector from 'Store/Selectors/createArtistSelector'; import createAlbumSelector from 'Store/Selectors/createAlbumSelector'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import QueueRow from './QueueRow'; @@ -15,11 +14,11 @@ function createMapStateToProps() { createAlbumSelector(), createUISettingsSelector(), (artist, album, uiSettings) => { - const result = _.pick(uiSettings, [ - 'showRelativeDates', - 'shortDateFormat', - 'timeFormat' - ]); + const result = { + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + timeFormat: uiSettings.timeFormat + }; result.artist = artist; result.album = album; @@ -41,20 +40,16 @@ class QueueRowConnector extends Component { onGrabPress = () => { this.props.grabQueueItem({ id: this.props.id }); - } + }; - onRemoveQueueItemPress = (blacklist) => { - this.props.removeQueueItem({ id: this.props.id, blacklist }); - } + onRemoveQueueItemPress = (payload) => { + this.props.removeQueueItem({ id: this.props.id, ...payload }); + }; // // Render render() { - if (!this.props.album) { - return null; - } - return ( { return ( -
+
{title}
    { @@ -37,70 +41,98 @@ function QueueStatusCell(props) { const { sourceTitle, status, - trackedDownloadStatus = 'Ok', + trackedDownloadStatus, + trackedDownloadState, statusMessages, errorMessage } = props; - const hasWarning = trackedDownloadStatus === 'Warning'; - const hasError = trackedDownloadStatus === 'Error'; + const hasWarning = trackedDownloadStatus === 'warning'; + const hasError = trackedDownloadStatus === 'error'; // status === 'downloading' let iconName = icons.DOWNLOADING; let iconKind = kinds.DEFAULT; - let title = 'Downloading'; + let title = translate('Downloading'); + + if (status === 'paused') { + iconName = icons.PAUSED; + title = translate('Paused'); + } + + if (status === 'queued') { + iconName = icons.QUEUED; + title = translate('Queued'); + } + + if (status === 'completed') { + iconName = icons.DOWNLOADED; + title = translate('Downloaded'); + + if (trackedDownloadState === 'importBlocked') { + title += ` - ${translate('UnableToImportAutomatically')}`; + iconKind = kinds.WARNING; + } + + if (trackedDownloadState === 'importFailed') { + title += ` - ${translate('ImportFailed', { sourceTitle })}`; + iconKind = kinds.WARNING; + } + + if (trackedDownloadState === 'importPending') { + title += ` - ${translate('WaitingToImport')}`; + iconKind = kinds.PURPLE; + } + + if (trackedDownloadState === 'importing') { + title += ` - ${translate('Importing')}`; + iconKind = kinds.PURPLE; + } + + if (trackedDownloadState === 'failedPending') { + title += ` - ${translate('WaitingToProcess')}`; + iconKind = kinds.DANGER; + } + } if (hasWarning) { iconKind = kinds.WARNING; } - if (status === 'Paused') { - iconName = icons.PAUSED; - title = 'Paused'; - } - - if (status === 'Queued') { - iconName = icons.QUEUED; - title = 'Queued'; - } - - if (status === 'Completed') { - iconName = icons.DOWNLOADED; - title = 'Downloaded'; - } - - if (status === 'Delay') { + if (status === 'delay') { iconName = icons.PENDING; - title = 'Pending'; + title = translate('Pending'); } - if (status === 'DownloadClientUnavailable') { + if (status === 'downloadClientUnavailable') { iconName = icons.PENDING; iconKind = kinds.WARNING; - title = 'Pending - Download client is unavailable'; + title = translate('PendingDownloadClientUnavailable'); } - if (status === 'Failed') { + if (status === 'failed') { iconName = icons.DOWNLOADING; iconKind = kinds.DANGER; - title = 'Download failed'; + title = translate('DownloadFailed'); } - if (status === 'Warning') { + if (status === 'warning') { iconName = icons.DOWNLOADING; iconKind = kinds.WARNING; - title = `Download warning: ${errorMessage || 'check download client for more details'}`; + const warningMessage = + errorMessage || translate('CheckDownloadClientForDetails'); + title = translate('DownloadWarning', { warningMessage }); } if (hasError) { - if (status === 'Completed') { + if (status === 'completed') { iconName = icons.DOWNLOAD; iconKind = kinds.DANGER; - title = `Import failed: ${sourceTitle}`; + title = translate('ImportFailed', { sourceTitle }); } else { iconName = icons.DOWNLOADING; iconKind = kinds.DANGER; - title = 'Download failed'; + title = translate('DownloadFailed'); } } @@ -116,6 +148,7 @@ function QueueStatusCell(props) { title={title} body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle} position={tooltipPositions.RIGHT} + canFlip={false} /> ); @@ -124,9 +157,15 @@ function QueueStatusCell(props) { QueueStatusCell.propTypes = { sourceTitle: PropTypes.string.isRequired, status: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string, + trackedDownloadStatus: PropTypes.string.isRequired, + trackedDownloadState: PropTypes.string.isRequired, statusMessages: PropTypes.arrayOf(PropTypes.object), errorMessage: PropTypes.string }; +QueueStatusCell.defaultProps = { + trackedDownloadStatus: 'Ok', + trackedDownloadState: 'Downloading' +}; + export default QueueStatusCell; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.css.d.ts b/frontend/src/Activity/Queue/RemoveQueueItemModal.css.d.ts new file mode 100644 index 000000000..65c237dff --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'message': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.js b/frontend/src/Activity/Queue/RemoveQueueItemModal.js deleted file mode 100644 index c69c70e1a..000000000 --- a/frontend/src/Activity/Queue/RemoveQueueItemModal.js +++ /dev/null @@ -1,114 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { inputTypes, kinds, sizes } from 'Helpers/Props'; -import Button from 'Components/Link/Button'; -import Modal from 'Components/Modal/Modal'; -import FormGroup from 'Components/Form/FormGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import styles from './RemoveQueueItemModal.css'; - -class RemoveQueueItemModal extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - blacklist: false - }; - } - - // - // Listeners - - onBlacklistChange = ({ value }) => { - this.setState({ blacklist: value }); - } - - onRemoveQueueItemConfirmed = () => { - const blacklist = this.state.blacklist; - - this.setState({ blacklist: false }); - this.props.onRemovePress(blacklist); - } - - onModalClose = () => { - this.setState({ blacklist: false }); - this.props.onModalClose(); - } - - // - // Render - - render() { - const { - isOpen, - sourceTitle - } = this.props; - - const blacklist = this.state.blacklist; - - return ( - - - - Remove - {sourceTitle} - - - -
    - Are you sure you want to remove '{sourceTitle}' from the queue? -
    - - - Blacklist Release - - - -
    - - - - - - -
    -
    - ); - } -} - -RemoveQueueItemModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - sourceTitle: PropTypes.string.isRequired, - onRemovePress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default RemoveQueueItemModal; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.tsx b/frontend/src/Activity/Queue/RemoveQueueItemModal.tsx new file mode 100644 index 000000000..f25bb0d1b --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.tsx @@ -0,0 +1,231 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './RemoveQueueItemModal.css'; + +interface RemovePressProps { + removeFromClient: boolean; + changeCategory: boolean; + blocklist: boolean; + skipRedownload: boolean; +} + +interface RemoveQueueItemModalProps { + isOpen: boolean; + sourceTitle: string; + canChangeCategory: boolean; + canIgnore: boolean; + isPending: boolean; + selectedCount?: number; + onRemovePress(props: RemovePressProps): void; + onModalClose: () => void; +} + +type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore'; +type BlocklistMethod = + | 'doNotBlocklist' + | 'blocklistAndSearch' + | 'blocklistOnly'; + +function RemoveQueueItemModal(props: RemoveQueueItemModalProps) { + const { + isOpen, + sourceTitle, + canIgnore, + canChangeCategory, + isPending, + selectedCount, + onRemovePress, + onModalClose, + } = props; + + const multipleSelected = selectedCount && selectedCount > 1; + + const [removalMethod, setRemovalMethod] = + useState('removeFromClient'); + const [blocklistMethod, setBlocklistMethod] = + useState('doNotBlocklist'); + + const { title, message } = useMemo(() => { + if (!selectedCount) { + return { + title: translate('RemoveQueueItem', { sourceTitle }), + message: translate('RemoveQueueItemConfirmation', { sourceTitle }), + }; + } + + if (selectedCount === 1) { + return { + title: translate('RemoveSelectedItem'), + message: translate('RemoveSelectedItemQueueMessageText'), + }; + } + + return { + title: translate('RemoveSelectedItems'), + message: translate('RemoveSelectedItemsQueueMessageText', { + selectedCount, + }), + }; + }, [sourceTitle, selectedCount]); + + const removalMethodOptions = useMemo(() => { + return [ + { + key: 'removeFromClient', + value: translate('RemoveFromDownloadClient'), + hint: multipleSelected + ? translate('RemoveMultipleFromDownloadClientHint') + : translate('RemoveFromDownloadClientHint'), + }, + { + key: 'changeCategory', + value: translate('ChangeCategory'), + isDisabled: !canChangeCategory, + hint: multipleSelected + ? translate('ChangeCategoryMultipleHint') + : translate('ChangeCategoryHint'), + }, + { + key: 'ignore', + value: multipleSelected + ? translate('IgnoreDownloads') + : translate('IgnoreDownload'), + isDisabled: !canIgnore, + hint: multipleSelected + ? translate('IgnoreDownloadsHint') + : translate('IgnoreDownloadHint'), + }, + ]; + }, [canChangeCategory, canIgnore, multipleSelected]); + + const blocklistMethodOptions = useMemo(() => { + return [ + { + key: 'doNotBlocklist', + value: translate('DoNotBlocklist'), + hint: translate('DoNotBlocklistHint'), + }, + { + key: 'blocklistAndSearch', + value: translate('BlocklistAndSearch'), + isDisabled: isPending, + hint: multipleSelected + ? translate('BlocklistAndSearchMultipleHint') + : translate('BlocklistAndSearchHint'), + }, + { + key: 'blocklistOnly', + value: translate('BlocklistOnly'), + hint: multipleSelected + ? translate('BlocklistMultipleOnlyHint') + : translate('BlocklistOnlyHint'), + }, + ]; + }, [isPending, multipleSelected]); + + const handleRemovalMethodChange = useCallback( + ({ value }: { value: RemovalMethod }) => { + setRemovalMethod(value); + }, + [setRemovalMethod] + ); + + const handleBlocklistMethodChange = useCallback( + ({ value }: { value: BlocklistMethod }) => { + setBlocklistMethod(value); + }, + [setBlocklistMethod] + ); + + const handleConfirmRemove = useCallback(() => { + onRemovePress({ + removeFromClient: removalMethod === 'removeFromClient', + changeCategory: removalMethod === 'changeCategory', + blocklist: blocklistMethod !== 'doNotBlocklist', + skipRedownload: blocklistMethod === 'blocklistOnly', + }); + + setRemovalMethod('removeFromClient'); + setBlocklistMethod('doNotBlocklist'); + }, [ + removalMethod, + blocklistMethod, + setRemovalMethod, + setBlocklistMethod, + onRemovePress, + ]); + + const handleModalClose = useCallback(() => { + setRemovalMethod('removeFromClient'); + setBlocklistMethod('doNotBlocklist'); + + onModalClose(); + }, [setRemovalMethod, setBlocklistMethod, onModalClose]); + + return ( + + + {title} + + +
    {message}
    + + {isPending ? null : ( + + {translate('RemoveQueueItemRemovalMethod')} + + + + )} + + + + {multipleSelected + ? translate('BlocklistReleases') + : translate('BlocklistRelease')} + + + + +
    + + + + + + +
    +
    + ); +} + +export default RemoveQueueItemModal; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.css b/frontend/src/Activity/Queue/RemoveQueueItemsModal.css deleted file mode 100644 index c9ef59ec1..000000000 --- a/frontend/src/Activity/Queue/RemoveQueueItemsModal.css +++ /dev/null @@ -1,3 +0,0 @@ -.message { - margin-bottom: 30px; -} diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js deleted file mode 100644 index 9d9229b29..000000000 --- a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js +++ /dev/null @@ -1,114 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { inputTypes, kinds, sizes } from 'Helpers/Props'; -import Button from 'Components/Link/Button'; -import Modal from 'Components/Modal/Modal'; -import FormGroup from 'Components/Form/FormGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import styles from './RemoveQueueItemsModal.css'; - -class RemoveQueueItemsModal extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - blacklist: false - }; - } - - // - // Listeners - - onBlacklistChange = ({ value }) => { - this.setState({ blacklist: value }); - } - - onRemoveQueueItemConfirmed = () => { - const blacklist = this.state.blacklist; - - this.setState({ blacklist: false }); - this.props.onRemovePress(blacklist); - } - - onModalClose = () => { - this.setState({ blacklist: false }); - this.props.onModalClose(); - } - - // - // Render - - render() { - const { - isOpen, - selectedCount - } = this.props; - - const blacklist = this.state.blacklist; - - return ( - - - - Remove Selected Item{selectedCount > 1 ? 's' : ''} - - - -
    - Are you sure you want to remove {selectedCount} item{selectedCount > 1 ? 's' : ''} from the queue? -
    - - - Blacklist Release - - - -
    - - - - - - -
    -
    - ); - } -} - -RemoveQueueItemsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - selectedCount: PropTypes.number.isRequired, - onRemovePress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default RemoveQueueItemsModal; diff --git a/frontend/src/Activity/Queue/Status/QueueStatusConnector.js b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js index e56b39187..7f58f2810 100644 --- a/frontend/src/Activity/Queue/Status/QueueStatusConnector.js +++ b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js @@ -2,19 +2,32 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { fetchQueueStatus } from 'Store/Actions/queueActions'; import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; +import { fetchQueueStatus } from 'Store/Actions/queueActions'; function createMapStateToProps() { return createSelector( (state) => state.app, (state) => state.queue.status, - (app, status) => { + (state) => state.queue.options.includeUnknownArtistItems, + (app, status, includeUnknownArtistItems) => { + const { + errors, + warnings, + unknownErrors, + unknownWarnings, + count, + totalCount + } = status.item; + return { isConnected: app.isConnected, isReconnecting: app.isReconnecting, isPopulated: status.isPopulated, - ...status.item + ...status.item, + count: includeUnknownArtistItems ? totalCount : count, + errors: includeUnknownArtistItems ? errors || unknownErrors : errors, + warnings: includeUnknownArtistItems ? warnings || unknownWarnings : warnings }; } ); diff --git a/frontend/src/Activity/Queue/TimeleftCell.css b/frontend/src/Activity/Queue/TimeleftCell.css index eb58cf297..cc6001a22 100644 --- a/frontend/src/Activity/Queue/TimeleftCell.css +++ b/frontend/src/Activity/Queue/TimeleftCell.css @@ -1,5 +1,5 @@ .timeleft { - composes: cell from 'Components/Table/Cells/TableRowCell.css'; + composes: cell from '~Components/Table/Cells/TableRowCell.css'; width: 100px; } diff --git a/frontend/src/Activity/Queue/TimeleftCell.css.d.ts b/frontend/src/Activity/Queue/TimeleftCell.css.d.ts new file mode 100644 index 000000000..f5c9402d1 --- /dev/null +++ b/frontend/src/Activity/Queue/TimeleftCell.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'timeleft': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/Queue/TimeleftCell.js b/frontend/src/Activity/Queue/TimeleftCell.js index c9515f172..b280b5a06 100644 --- a/frontend/src/Activity/Queue/TimeleftCell.js +++ b/frontend/src/Activity/Queue/TimeleftCell.js @@ -1,10 +1,14 @@ import PropTypes from 'prop-types'; import React from 'react'; +import Icon from 'Components/Icon'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import formatTime from 'Utilities/Date/formatTime'; import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; import getRelativeDate from 'Utilities/Date/getRelativeDate'; import formatBytes from 'Utilities/Number/formatBytes'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import translate from 'Utilities/String/translate'; import styles from './TimeleftCell.css'; function TimeleftCell(props) { @@ -19,35 +23,39 @@ function TimeleftCell(props) { timeFormat } = props; - if (status === 'Delay') { + if (status === 'delay') { const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates); const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); return ( - - - + + } + tooltip={translate('DelayingDownloadUntil', { date, time })} + kind={kinds.INVERSE} + position={tooltipPositions.TOP} + /> ); } - if (status === 'DownloadClientUnavailable') { + if (status === 'downloadClientUnavailable') { const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates); const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); return ( - - - + + } + tooltip={translate('RetryingDownloadOn', { date, time })} + kind={kinds.INVERSE} + position={tooltipPositions.TOP} + /> ); } - if (!timeleft) { + if (!timeleft || status === 'completed' || status === 'failed') { return ( - diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtist.css b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.css deleted file mode 100644 index c1ec4fbe3..000000000 --- a/frontend/src/AddArtist/AddNewArtist/AddNewArtist.css +++ /dev/null @@ -1,54 +0,0 @@ -.searchContainer { - display: flex; - margin-bottom: 10px; -} - -.searchIconContainer { - width: 58px; - height: 46px; - border: 1px solid $inputBorderColor; - border-right: none; - border-radius: 4px; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - background-color: #edf1f2; - text-align: center; - line-height: 46px; -} - -.searchInput { - composes: text from 'Components/Form/TextInput.css'; - - height: 46px; - border-radius: 0; - font-size: 18px; -} - -.clearLookupButton { - border: 1px solid $inputBorderColor; - border-left: none; - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); -} - -.message { - margin-top: 30px; - text-align: center; -} - -.helpText { - margin-bottom: 10px; - font-weight: 300; - font-size: 24px; -} - -.noResults { - margin-bottom: 10px; - font-weight: 300; - font-size: 30px; -} - -.searchResults { - margin-top: 30px; -} diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js deleted file mode 100644 index 3b1959982..000000000 --- a/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js +++ /dev/null @@ -1,183 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { icons } from 'Helpers/Props'; -import Button from 'Components/Link/Button'; -import Link from 'Components/Link/Link'; -import Icon from 'Components/Icon'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import TextInput from 'Components/Form/TextInput'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; -import AddNewArtistSearchResultConnector from './AddNewArtistSearchResultConnector'; -import styles from './AddNewArtist.css'; - -class AddNewArtist extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - term: props.term || '', - isFetching: false - }; - } - - componentDidMount() { - const term = this.state.term; - - if (term) { - this.props.onArtistLookupChange(term); - } - } - - componentDidUpdate(prevProps) { - const { - term, - isFetching - } = this.props; - - if (term && term !== prevProps.term) { - this.setState({ - term, - isFetching: true - }); - this.props.onArtistLookupChange(term); - } else if (isFetching !== prevProps.isFetching) { - this.setState({ - isFetching - }); - } - } - - // - // Listeners - - onSearchInputChange = ({ value }) => { - const hasValue = !!value.trim(); - - this.setState({ term: value, isFetching: hasValue }, () => { - if (hasValue) { - this.props.onArtistLookupChange(value); - } else { - this.props.onClearArtistLookup(); - } - }); - } - - onClearArtistLookupPress = () => { - this.setState({ term: '' }); - this.props.onClearArtistLookup(); - } - - // - // Render - - render() { - const { - error, - items - } = this.props; - - const term = this.state.term; - const isFetching = this.state.isFetching; - - return ( - - -
    -
    - -
    - - - - -
    - - { - isFetching && - - } - - { - !isFetching && !!error && -
    Failed to load search results, please try again.
    - } - - { - !isFetching && !error && !!items.length && -
    - { - items.map((item) => { - return ( - - ); - }) - } -
    - } - - { - !isFetching && !error && !items.length && !!term && -
    -
    Couldn't find any results for '{term}'
    -
    You can also search using MusicBrainz ID of an artist. eg. lidarr:cc197bad-dc9c-440d-a5b5-d52ba2e14234
    -
    - - Why can't I find my artist? - -
    -
    - } - - { - !term && -
    -
    It's easy to add a new artist, just start typing the name the artist you want to add.
    -
    You can also search using MusicBrainz ID of an artist. eg. lidarr:cc197bad-dc9c-440d-a5b5-d52ba2e14234
    -
    - } - -
    - - - ); - } -} - -AddNewArtist.propTypes = { - term: PropTypes.string, - isFetching: PropTypes.bool.isRequired, - error: PropTypes.object, - isAdding: PropTypes.bool.isRequired, - addError: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - onArtistLookupChange: PropTypes.func.isRequired, - onClearArtistLookup: PropTypes.func.isRequired -}; - -export default AddNewArtist; diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistConnector.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistConnector.js deleted file mode 100644 index a2e322b40..000000000 --- a/frontend/src/AddArtist/AddNewArtist/AddNewArtistConnector.js +++ /dev/null @@ -1,102 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import queryString from 'query-string'; -import { lookupArtist, clearAddArtist } from 'Store/Actions/addArtistActions'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; -import AddNewArtist from './AddNewArtist'; - -function createMapStateToProps() { - return createSelector( - (state) => state.addArtist, - (state) => state.routing.location, - (addArtist, location) => { - const query = queryString.parse(location.search); - - return { - term: query.term, - ...addArtist - }; - } - ); -} - -const mapDispatchToProps = { - lookupArtist, - clearAddArtist, - fetchRootFolders -}; - -class AddNewArtistConnector extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._artistLookupTimeout = null; - } - - componentDidMount() { - this.props.fetchRootFolders(); - } - - componentWillUnmount() { - if (this._artistLookupTimeout) { - clearTimeout(this._artistLookupTimeout); - } - - this.props.clearAddArtist(); - } - - // - // Listeners - - onArtistLookupChange = (term) => { - if (this._artistLookupTimeout) { - clearTimeout(this._artistLookupTimeout); - } - - if (term.trim() === '') { - this.props.clearAddArtist(); - } else { - this._artistLookupTimeout = setTimeout(() => { - this.props.lookupArtist({ term }); - }, 300); - } - } - - onClearArtistLookup = () => { - this.props.clearAddArtist(); - } - - // - // Render - - render() { - const { - term, - ...otherProps - } = this.props; - - return ( - - ); - } -} - -AddNewArtistConnector.propTypes = { - term: PropTypes.string, - lookupArtist: PropTypes.func.isRequired, - clearAddArtist: PropTypes.func.isRequired, - fetchRootFolders: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(AddNewArtistConnector); diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.css b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.css deleted file mode 100644 index 22862a8ca..000000000 --- a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.css +++ /dev/null @@ -1,77 +0,0 @@ -.container { - display: flex; -} - -.year { - margin-left: 5px; - color: $disabledColor; -} - -.poster { - flex: 0 0 170px; - margin-right: 20px; - height: 250px; -} - -.info { - flex-grow: 1; -} - -.overview { - margin-bottom: 30px; - max-height: 230px; - text-align: justify; -} - -.labelIcon { - margin-left: 8px; -} - -.searchForMissingAlbumsLabelContainer { - display: flex; - margin-top: 2px; -} - -.searchForMissingAlbumsLabel { - margin-right: 8px; - font-weight: normal; -} - -.searchForMissingAlbumsContainer { - composes: container from 'Components/Form/CheckInput.css'; - - flex: 0 1 0; -} - -.searchForMissingAlbumsInput { - composes: input from 'Components/Form/CheckInput.css'; - - margin-top: 0; -} - -.modalFooter { - composes: modalFooter from 'Components/Modal/ModalFooter.css'; -} - -.addButton { - @add-mixin truncate; - composes: button from 'Components/Link/SpinnerButton.css'; -} - -.hideLanguageProfile, -.hideMetadataProfile { - composes: group from 'Components/Form/FormGroup.css'; - - display: none; -} - -@media only screen and (max-width: $breakpointSmall) { - .modalFooter { - display: block; - text-align: center; - } - - .addButton { - margin-top: 10px; - } -} diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.js deleted file mode 100644 index a6a6084ad..000000000 --- a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.js +++ /dev/null @@ -1,254 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import TextTruncate from 'react-text-truncate'; -import { icons, kinds, inputTypes, tooltipPositions } from 'Helpers/Props'; -import Icon from 'Components/Icon'; -import SpinnerButton from 'Components/Link/SpinnerButton'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import CheckInput from 'Components/Form/CheckInput'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import Popover from 'Components/Tooltip/Popover'; -import ArtistPoster from 'Artist/ArtistPoster'; -import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent'; -import styles from './AddNewArtistModalContent.css'; - -class AddNewArtistModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - searchForMissingAlbums: false - }; - } - - // - // Listeners - - onSearchForMissingAlbumsChange = ({ value }) => { - this.setState({ searchForMissingAlbums: value }); - } - - onQualityProfileIdChange = ({ value }) => { - this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) }); - } - - onLanguageProfileIdChange = ({ value }) => { - this.props.onInputChange({ name: 'languageProfileId', value: parseInt(value) }); - } - - onMetadataProfileIdChange = ({ value }) => { - this.props.onInputChange({ name: 'metadataProfileId', value: parseInt(value) }); - } - - onAddArtistPress = () => { - this.props.onAddArtistPress(this.state.searchForMissingAlbums); - } - - // - // Render - - render() { - const { - artistName, - overview, - images, - isAdding, - rootFolderPath, - monitor, - qualityProfileId, - languageProfileId, - metadataProfileId, - albumFolder, - tags, - showLanguageProfile, - showMetadataProfile, - isSmallScreen, - onModalClose, - onInputChange - } = this.props; - - return ( - - - {artistName} - - - -
    - { - !isSmallScreen && -
    - -
    - } - -
    -
    - -
    - -
    - - Root Folder - - - - - - - Monitor - - - } - title="Monitoring Options" - body={} - position={tooltipPositions.RIGHT} - /> - - - - - - - Quality Profile - - - - - - Language Profile - - - - - - Metadata Profile - - - - - - Album Folder - - - - - - Tags - - - - -
    -
    -
    - - - - - - Add {artistName} - - -
    - ); - } -} - -AddNewArtistModalContent.propTypes = { - artistName: PropTypes.string.isRequired, - overview: PropTypes.string, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - isAdding: PropTypes.bool.isRequired, - addError: PropTypes.object, - rootFolderPath: PropTypes.object, - monitor: PropTypes.object.isRequired, - qualityProfileId: PropTypes.object, - languageProfileId: PropTypes.object, - metadataProfileId: PropTypes.object, - albumFolder: PropTypes.object.isRequired, - tags: PropTypes.object.isRequired, - showLanguageProfile: PropTypes.bool.isRequired, - showMetadataProfile: PropTypes.bool.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired, - onInputChange: PropTypes.func.isRequired, - onAddArtistPress: PropTypes.func.isRequired -}; - -export default AddNewArtistModalContent; diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContentConnector.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContentConnector.js deleted file mode 100644 index a0163bb07..000000000 --- a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContentConnector.js +++ /dev/null @@ -1,110 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setAddArtistDefault, addArtist } from 'Store/Actions/addArtistActions'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import selectSettings from 'Store/Selectors/selectSettings'; -import AddNewArtistModalContent from './AddNewArtistModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.addArtist, - (state) => state.settings.languageProfiles, - (state) => state.settings.metadataProfiles, - createDimensionsSelector(), - (addArtistState, languageProfiles, metadataProfiles, dimensions) => { - const { - isAdding, - addError, - defaults - } = addArtistState; - - const { - settings, - validationErrors, - validationWarnings - } = selectSettings(defaults, {}, addError); - - return { - isAdding, - addError, - showLanguageProfile: languageProfiles.items.length > 1, - showMetadataProfile: metadataProfiles.items.length > 1, - isSmallScreen: dimensions.isSmallScreen, - validationErrors, - validationWarnings, - ...settings - }; - } - ); -} - -const mapDispatchToProps = { - setAddArtistDefault, - addArtist -}; - -class AddNewArtistModalContentConnector extends Component { - - // - // Listeners - - onInputChange = ({ name, value }) => { - this.props.setAddArtistDefault({ [name]: value }); - } - - onAddArtistPress = (searchForMissingAlbums) => { - const { - foreignArtistId, - rootFolderPath, - monitor, - qualityProfileId, - languageProfileId, - metadataProfileId, - albumFolder, - tags - } = this.props; - - this.props.addArtist({ - foreignArtistId, - rootFolderPath: rootFolderPath.value, - monitor: monitor.value, - qualityProfileId: qualityProfileId.value, - languageProfileId: languageProfileId.value, - metadataProfileId: metadataProfileId.value, - albumFolder: albumFolder.value, - tags: tags.value, - searchForMissingAlbums - }); - } - - // - // Render - - render() { - return ( - - ); - } -} - -AddNewArtistModalContentConnector.propTypes = { - foreignArtistId: PropTypes.string.isRequired, - rootFolderPath: PropTypes.object, - monitor: PropTypes.object.isRequired, - qualityProfileId: PropTypes.object, - languageProfileId: PropTypes.object, - metadataProfileId: PropTypes.object, - albumFolder: PropTypes.object.isRequired, - tags: PropTypes.object.isRequired, - onModalClose: PropTypes.func.isRequired, - setAddArtistDefault: PropTypes.func.isRequired, - addArtist: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(AddNewArtistModalContentConnector); diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.css b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.css deleted file mode 100644 index c56765538..000000000 --- a/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.css +++ /dev/null @@ -1,42 +0,0 @@ -.searchResult { - display: flex; - margin: 20px 0; - padding: 20px; - width: 100%; - background-color: $white; - color: inherit; - transition: background 500ms; - - &:hover { - background-color: #eaf2ff; - color: inherit; - text-decoration: none; - } -} - -.poster { - flex: 0 0 170px; - margin-right: 20px; - height: 250px; -} - -.name { - font-weight: 300; - font-size: 36px; -} - -.year { - margin-left: 10px; - color: $disabledColor; -} - -.alreadyExistsIcon { - margin-left: 10px; - color: #37bc9b; -} - -.overview { - overflow: hidden; - margin-top: 20px; - text-align: justify; -} diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.js deleted file mode 100644 index 38a60bef8..000000000 --- a/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.js +++ /dev/null @@ -1,197 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import TextTruncate from 'react-text-truncate'; -import dimensions from 'Styles/Variables/dimensions'; -import fonts from 'Styles/Variables/fonts'; -import { icons, kinds, sizes } from 'Helpers/Props'; -import HeartRating from 'Components/HeartRating'; -import Icon from 'Components/Icon'; -import Label from 'Components/Label'; -import Link from 'Components/Link/Link'; -import ArtistPoster from 'Artist/ArtistPoster'; -import AddNewArtistModal from './AddNewArtistModal'; -import styles from './AddNewArtistSearchResult.css'; - -const columnPadding = parseInt(dimensions.artistIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); -const defaultFontSize = parseInt(fonts.defaultFontSize); -const lineHeight = parseFloat(fonts.lineHeight); - -function calculateHeight(rowHeight, isSmallScreen) { - let height = rowHeight - 45; - - if (isSmallScreen) { - height -= columnPaddingSmallScreen; - } else { - height -= columnPadding; - } - - return height; -} - -class AddNewArtistSearchResult extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isNewAddArtistModalOpen: false - }; - } - - componentDidUpdate(prevProps) { - if (!prevProps.isExistingArtist && this.props.isExistingArtist) { - this.onAddArtistModalClose(); - } - } - - // - // Listeners - - onPress = () => { - this.setState({ isNewAddArtistModalOpen: true }); - } - - onAddArtistModalClose = () => { - this.setState({ isNewAddArtistModalOpen: false }); - } - - // - // Render - - render() { - const { - foreignArtistId, - artistName, - year, - disambiguation, - artistType, - status, - overview, - ratings, - images, - isExistingArtist, - isSmallScreen - } = this.props; - - const { - isNewAddArtistModalOpen - } = this.state; - - const linkProps = isExistingArtist ? { to: `/artist/${foreignArtistId}` } : { onPress: this.onPress }; - - const height = calculateHeight(230, isSmallScreen); - - return ( -
    - - { - !isSmallScreen && - - } - -
    -
    - {artistName} - - { - !name.contains(year) && !!year && - ({year}) - } - - { - !!disambiguation && - ({disambiguation}) - } - - { - isExistingArtist && - - } -
    - -
    - - - { - !!artistType && - - } - - { - status === 'ended' && - - } -
    - -
    - -
    -
    - - - -
    - ); - } -} - -AddNewArtistSearchResult.propTypes = { - foreignArtistId: PropTypes.string.isRequired, - artistName: PropTypes.string.isRequired, - year: PropTypes.number, - disambiguation: PropTypes.string, - artistType: PropTypes.string, - status: PropTypes.string.isRequired, - overview: PropTypes.string, - ratings: PropTypes.object.isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - isExistingArtist: PropTypes.bool.isRequired, - isSmallScreen: PropTypes.bool.isRequired -}; - -export default AddNewArtistSearchResult; diff --git a/frontend/src/AddArtist/ArtistMetadataProfilePopoverContent.js b/frontend/src/AddArtist/ArtistMetadataProfilePopoverContent.js new file mode 100644 index 000000000..d7c663674 --- /dev/null +++ b/frontend/src/AddArtist/ArtistMetadataProfilePopoverContent.js @@ -0,0 +1,11 @@ +import React from 'react'; + +function ArtistMetadataProfilePopoverContent() { + return ( +
    + Select 'None' to only include items manually added via search or that match files on disk +
    + ); +} + +export default ArtistMetadataProfilePopoverContent; diff --git a/frontend/src/AddArtist/ArtistMonitorNewItemsOptionsPopoverContent.js b/frontend/src/AddArtist/ArtistMonitorNewItemsOptionsPopoverContent.js new file mode 100644 index 000000000..cda224e2f --- /dev/null +++ b/frontend/src/AddArtist/ArtistMonitorNewItemsOptionsPopoverContent.js @@ -0,0 +1,27 @@ +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import translate from 'Utilities/String/translate'; + +function ArtistMonitorNewItemsOptionsPopoverContent() { + return ( + + + + + + + + ); +} + +export default ArtistMonitorNewItemsOptionsPopoverContent; diff --git a/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css new file mode 100644 index 000000000..7393b9c35 --- /dev/null +++ b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css @@ -0,0 +1,5 @@ +.message { + composes: alert from '~Components/Alert.css'; + + margin-bottom: 30px; +} diff --git a/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css.d.ts b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css.d.ts new file mode 100644 index 000000000..65c237dff --- /dev/null +++ b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'message': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js index 89851ce4d..d53bda8e3 100644 --- a/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js +++ b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js @@ -1,45 +1,55 @@ import React from 'react'; +import Alert from 'Components/Alert'; import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import { kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './ArtistMonitoringOptionsPopoverContent.css'; function ArtistMonitoringOptionsPopoverContent() { return ( - - + <> + + This is a one time adjustment to set which albums are monitored + - + + - + - + - + - + - - + + + + + ); } diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js deleted file mode 100644 index fc8ad079c..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js +++ /dev/null @@ -1,177 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import getSelectedIds from 'Utilities/Table/getSelectedIds'; -import selectAll from 'Utilities/Table/selectAll'; -import toggleSelected from 'Utilities/Table/toggleSelected'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; -import ImportArtistTableConnector from './ImportArtistTableConnector'; -import ImportArtistFooterConnector from './ImportArtistFooterConnector'; - -class ImportArtist extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - allSelected: false, - allUnselected: false, - lastToggled: null, - selectedState: {}, - contentBody: null, - scrollTop: 0 - }; - } - - // - // Control - - setContentBodyRef = (ref) => { - this.setState({ contentBody: ref }); - } - - // - // Listeners - - getSelectedIds = () => { - return getSelectedIds(this.state.selectedState, { parseIds: false }); - } - - onSelectAllChange = ({ value }) => { - // Only select non-dupes - this.setState(selectAll(this.state.selectedState, value)); - } - - onSelectedChange = ({ id, value, shiftKey = false }) => { - this.setState((state) => { - return toggleSelected(state, this.props.items, id, value, shiftKey); - }); - } - - onRemoveSelectedStateItem = (id) => { - this.setState((state) => { - const selectedState = Object.assign({}, state.selectedState); - delete selectedState[id]; - - return { - ...state, - selectedState - }; - }); - } - - onInputChange = ({ name, value }) => { - this.props.onInputChange(this.getSelectedIds(), name, value); - } - - onImportPress = () => { - this.props.onImportPress(this.getSelectedIds()); - } - - onScroll = ({ scrollTop }) => { - this.setState({ scrollTop }); - } - - // - // Render - - render() { - const { - rootFolderId, - path, - rootFoldersFetching, - rootFoldersPopulated, - rootFoldersError, - unmappedFolders, - showLanguageProfile, - showMetadataProfile - } = this.props; - - const { - allSelected, - allUnselected, - selectedState, - contentBody - } = this.state; - - return ( - - - { - rootFoldersFetching && !rootFoldersPopulated && - - } - - { - !rootFoldersFetching && !!rootFoldersError && -
    Unable to load root folders
    - } - - { - !rootFoldersError && rootFoldersPopulated && !unmappedFolders.length && -
    - All artist in {path} have been imported -
    - } - - { - !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && contentBody && - - } -
    - - { - !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && - - } -
    - ); - } -} - -ImportArtist.propTypes = { - rootFolderId: PropTypes.number.isRequired, - path: PropTypes.string, - rootFoldersFetching: PropTypes.bool.isRequired, - rootFoldersPopulated: PropTypes.bool.isRequired, - rootFoldersError: PropTypes.object, - unmappedFolders: PropTypes.arrayOf(PropTypes.object), - items: PropTypes.arrayOf(PropTypes.object), - showLanguageProfile: PropTypes.bool.isRequired, - showMetadataProfile: PropTypes.bool.isRequired, - onInputChange: PropTypes.func.isRequired, - onImportPress: PropTypes.func.isRequired -}; - -ImportArtist.defaultProps = { - unmappedFolders: [] -}; - -export default ImportArtist; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js deleted file mode 100644 index 1464ef557..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js +++ /dev/null @@ -1,124 +0,0 @@ -/* eslint max-params: 0 */ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setImportArtistValue, importArtist, clearImportArtist } from 'Store/Actions/importArtistActions'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; -import { setAddArtistDefault } from 'Store/Actions/addArtistActions'; -import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape'; -import ImportArtist from './ImportArtist'; - -function createMapStateToProps() { - return createSelector( - (state, { match }) => match, - (state) => state.rootFolders, - (state) => state.addArtist, - (state) => state.importArtist, - (state) => state.settings.languageProfiles, - (state) => state.settings.metadataProfiles, - (match, rootFolders, addArtist, importArtistState, languageProfiles, metadataProfiles) => { - const { - isFetching: rootFoldersFetching, - isPopulated: rootFoldersPopulated, - error: rootFoldersError, - items - } = rootFolders; - - const rootFolderId = parseInt(match.params.rootFolderId); - - const result = { - rootFolderId, - rootFoldersFetching, - rootFoldersPopulated, - rootFoldersError, - showLanguageProfile: languageProfiles.items.length > 1, - showMetadataProfile: metadataProfiles.items.length > 1 - }; - - if (items.length) { - const rootFolder = _.find(items, { id: rootFolderId }); - - return { - ...result, - ...rootFolder, - items: importArtistState.items - }; - } - - return result; - } - ); -} - -const mapDispatchToProps = { - setImportArtistValue, - importArtist, - clearImportArtist, - fetchRootFolders, - setAddArtistDefault -}; - -class ImportArtistConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - if (!this.props.rootFoldersPopulated) { - this.props.fetchRootFolders(); - } - } - - componentWillUnmount() { - this.props.clearImportArtist(); - } - - // - // Listeners - - onInputChange = (ids, name, value) => { - this.props.setAddArtistDefault({ [name]: value }); - - ids.forEach((id) => { - this.props.setImportArtistValue({ - id, - [name]: value - }); - }); - } - - onImportPress = (ids) => { - this.props.importArtist({ ids }); - } - - // - // Render - - render() { - return ( - - ); - } -} - -const routeMatchShape = createRouteMatchShape({ - rootFolderId: PropTypes.string.isRequired -}); - -ImportArtistConnector.propTypes = { - match: routeMatchShape.isRequired, - rootFoldersPopulated: PropTypes.bool.isRequired, - setImportArtistValue: PropTypes.func.isRequired, - importArtist: PropTypes.func.isRequired, - clearImportArtist: PropTypes.func.isRequired, - fetchRootFolders: PropTypes.func.isRequired, - setAddArtistDefault: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistConnector); diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css deleted file mode 100644 index 0a61ca509..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css +++ /dev/null @@ -1,33 +0,0 @@ -.inputContainer { - margin-right: 20px; - min-width: 150px; -} - -.label { - margin-bottom: 3px; - font-weight: bold; -} - -.importButtonContainer { - display: flex; - align-items: center; -} - -.importButton { - composes: button from 'Components/Link/SpinnerButton.css'; - - height: 35px; -} - -.loadingButton { - composes: importButton; - - margin-left: 10px; -} - -.loading { - composes: loading from 'Components/Loading/LoadingIndicator.css'; - - margin: 0 10px 0 12px; - text-align: left; -} diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js deleted file mode 100644 index 6cae9f6e2..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js +++ /dev/null @@ -1,281 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { inputTypes, kinds } from 'Helpers/Props'; -import Button from 'Components/Link/Button'; -import SpinnerButton from 'Components/Link/SpinnerButton'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import CheckInput from 'Components/Form/CheckInput'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import PageContentFooter from 'Components/Page/PageContentFooter'; -import styles from './ImportArtistFooter.css'; - -const MIXED = 'mixed'; - -class ImportArtistFooter extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - const { - defaultMonitor, - defaultQualityProfileId, - defaultLanguageProfileId, - defaultMetadataProfileId, - defaultAlbumFolder - } = props; - - this.state = { - monitor: defaultMonitor, - qualityProfileId: defaultQualityProfileId, - languageProfileId: defaultLanguageProfileId, - metadataProfileId: defaultMetadataProfileId, - albumFolder: defaultAlbumFolder - }; - } - - componentDidUpdate(prevProps, prevState) { - const { - defaultMonitor, - defaultQualityProfileId, - defaultLanguageProfileId, - defaultMetadataProfileId, - defaultAlbumFolder, - isMonitorMixed, - isQualityProfileIdMixed, - isLanguageProfileIdMixed, - isMetadataProfileIdMixed, - isAlbumFolderMixed - } = this.props; - - const { - monitor, - qualityProfileId, - languageProfileId, - metadataProfileId, - albumFolder - } = this.state; - - const newState = {}; - - if (isMonitorMixed && monitor !== MIXED) { - newState.monitor = MIXED; - } else if (!isMonitorMixed && monitor !== defaultMonitor) { - newState.monitor = defaultMonitor; - } - - if (isQualityProfileIdMixed && qualityProfileId !== MIXED) { - newState.qualityProfileId = MIXED; - } else if (!isQualityProfileIdMixed && qualityProfileId !== defaultQualityProfileId) { - newState.qualityProfileId = defaultQualityProfileId; - } - - if (isLanguageProfileIdMixed && languageProfileId !== MIXED) { - newState.languageProfileId = MIXED; - } else if (!isLanguageProfileIdMixed && languageProfileId !== defaultLanguageProfileId) { - newState.languageProfileId = defaultLanguageProfileId; - } - - if (isMetadataProfileIdMixed && metadataProfileId !== MIXED) { - newState.metadataProfileId = MIXED; - } else if (!isMetadataProfileIdMixed && metadataProfileId !== defaultMetadataProfileId) { - newState.metadataProfileId = defaultMetadataProfileId; - } - - if (isAlbumFolderMixed && albumFolder != null) { - newState.albumFolder = null; - } else if (!isAlbumFolderMixed && albumFolder !== defaultAlbumFolder) { - newState.albumFolder = defaultAlbumFolder; - } - - if (!_.isEmpty(newState)) { - this.setState(newState); - } - } - - // - // Listeners - - onInputChange = ({ name, value }) => { - this.setState({ [name]: value }); - this.props.onInputChange({ name, value }); - } - - // - // Render - - render() { - const { - selectedCount, - isImporting, - isLookingUpArtist, - isMonitorMixed, - isQualityProfileIdMixed, - isLanguageProfileIdMixed, - isMetadataProfileIdMixed, - showLanguageProfile, - showMetadataProfile, - onImportPress, - onCancelLookupPress - } = this.props; - - const { - monitor, - qualityProfileId, - languageProfileId, - metadataProfileId, - albumFolder - } = this.state; - - return ( - -
    -
    - Monitor -
    - - -
    - -
    -
    - Quality Profile -
    - - -
    - - { - showLanguageProfile && -
    -
    - Language Profile -
    - - -
    - } - - { - showMetadataProfile && -
    -
    - Metadata Profile -
    - - -
    - } - -
    -
    - Album Folder -
    - - -
    - -
    -
    -   -
    - -
    - - Import {selectedCount} Artist(s) - - - { - isLookingUpArtist && - - } - - { - isLookingUpArtist && - - } - - { - isLookingUpArtist && - 'Processing Folders' - } -
    -
    -
    - ); - } -} - -ImportArtistFooter.propTypes = { - selectedCount: PropTypes.number.isRequired, - isImporting: PropTypes.bool.isRequired, - isLookingUpArtist: PropTypes.bool.isRequired, - defaultMonitor: PropTypes.string.isRequired, - defaultQualityProfileId: PropTypes.number, - defaultLanguageProfileId: PropTypes.number, - defaultMetadataProfileId: PropTypes.number, - defaultAlbumFolder: PropTypes.bool.isRequired, - isMonitorMixed: PropTypes.bool.isRequired, - isQualityProfileIdMixed: PropTypes.bool.isRequired, - isLanguageProfileIdMixed: PropTypes.bool.isRequired, - isMetadataProfileIdMixed: PropTypes.bool.isRequired, - isAlbumFolderMixed: PropTypes.bool.isRequired, - showLanguageProfile: PropTypes.bool.isRequired, - showMetadataProfile: PropTypes.bool.isRequired, - onInputChange: PropTypes.func.isRequired, - onImportPress: PropTypes.func.isRequired, - onCancelLookupPress: PropTypes.func.isRequired -}; - -export default ImportArtistFooter; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js deleted file mode 100644 index ede45dffd..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js +++ /dev/null @@ -1,62 +0,0 @@ -import _ from 'lodash'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import ImportArtistFooter from './ImportArtistFooter'; -import { cancelLookupArtist } from 'Store/Actions/importArtistActions'; - -function isMixed(items, selectedIds, defaultValue, key) { - return _.some(items, (artist) => { - return selectedIds.indexOf(artist.id) > -1 && artist[key] !== defaultValue; - }); -} - -function createMapStateToProps() { - return createSelector( - (state) => state.addArtist, - (state) => state.importArtist, - (state, { selectedIds }) => selectedIds, - (addArtist, importArtist, selectedIds) => { - const { - monitor: defaultMonitor, - qualityProfileId: defaultQualityProfileId, - languageProfileId: defaultLanguageProfileId, - metadataProfileId: defaultMetadataProfileId, - albumFolder: defaultAlbumFolder - } = addArtist.defaults; - - const { - isLookingUpArtist, - isImporting, - items - } = importArtist; - - const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor'); - const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId'); - const isLanguageProfileIdMixed = isMixed(items, selectedIds, defaultLanguageProfileId, 'languageProfileId'); - const isMetadataProfileIdMixed = isMixed(items, selectedIds, defaultMetadataProfileId, 'metadataProfileId'); - const isAlbumFolderMixed = isMixed(items, selectedIds, defaultAlbumFolder, 'albumFolder'); - - return { - selectedCount: selectedIds.length, - isLookingUpArtist, - isImporting, - defaultMonitor, - defaultQualityProfileId, - defaultLanguageProfileId, - defaultMetadataProfileId, - defaultAlbumFolder, - isMonitorMixed, - isQualityProfileIdMixed, - isLanguageProfileIdMixed, - isMetadataProfileIdMixed, - isAlbumFolderMixed - }; - } - ); -} - -const mapDispatchToProps = { - onCancelLookupPress: cancelLookupArtist -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistFooter); diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css deleted file mode 100644 index f43704565..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css +++ /dev/null @@ -1,39 +0,0 @@ -.folder { - composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; - - flex: 1 0 200px; -} - -.monitor { - composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; - - flex: 0 1 200px; - min-width: 185px; -} - -.qualityProfile, -.languageProfile, -.metadataProfile { - composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; - - flex: 0 1 250px; - min-width: 170px; -} - -.albumFolder { - composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; - - flex: 0 1 150px; - min-width: 120px; -} - -.artist { - composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; - - flex: 0 1 400px; - min-width: 300px; -} - -.detailsIcon { - margin-left: 8px; -} diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js deleted file mode 100644 index f0ec10566..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js +++ /dev/null @@ -1,108 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { icons, tooltipPositions } from 'Helpers/Props'; -import Icon from 'Components/Icon'; -import Popover from 'Components/Tooltip/Popover'; -import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; -import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; -import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell'; -import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent'; -// import SeriesTypePopoverContent from 'AddArtist/SeriesTypePopoverContent'; -import styles from './ImportArtistHeader.css'; - -function ImportArtistHeader(props) { - const { - showLanguageProfile, - showMetadataProfile, - allSelected, - allUnselected, - onSelectAllChange - } = props; - - return ( - - - - - Folder - - - - Monitor - - - } - title="Monitoring Options" - body={} - position={tooltipPositions.RIGHT} - /> - - - - Quality Profile - - - { - showLanguageProfile && - - Language Profile - - } - - { - showMetadataProfile && - - Metadata Profile - - } - - - Album Folder - - - - Artist - - - ); -} - -ImportArtistHeader.propTypes = { - showLanguageProfile: PropTypes.bool.isRequired, - showMetadataProfile: PropTypes.bool.isRequired, - allSelected: PropTypes.bool.isRequired, - allUnselected: PropTypes.bool.isRequired, - onSelectAllChange: PropTypes.func.isRequired -}; - -export default ImportArtistHeader; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.css deleted file mode 100644 index fc6310597..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.css +++ /dev/null @@ -1,47 +0,0 @@ -.selectInput { - composes: input from 'Components/Form/CheckInput.css'; -} - -.folder { - composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; - - flex: 1 0 200px; - line-height: 36px; -} - -.monitor { - composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; - - flex: 0 1 200px; - min-width: 185px; -} - -.qualityProfile, -.languageProfile, -.metadataProfile { - composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; - - flex: 0 1 250px; - min-width: 170px; -} - -.albumFolder { - composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; - - flex: 0 1 150px; - min-width: 120px; -} - -.artist { - composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; - - flex: 0 1 400px; - min-width: 300px; -} - -.hideLanguageProfile, -.hideMetadataProfile { - composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; - - display: none; -} diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js deleted file mode 100644 index 0d2064e65..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js +++ /dev/null @@ -1,125 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { inputTypes } from 'Helpers/Props'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import VirtualTableRow from 'Components/Table/VirtualTableRow'; -import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; -import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; -import ImportArtistSelectArtistConnector from './SelectArtist/ImportArtistSelectArtistConnector'; -import styles from './ImportArtistRow.css'; - -function ImportArtistRow(props) { - const { - style, - id, - monitor, - qualityProfileId, - languageProfileId, - metadataProfileId, - albumFolder, - selectedArtist, - isExistingArtist, - showLanguageProfile, - showMetadataProfile, - isSelected, - onSelectedChange, - onInputChange - } = props; - - return ( - - - - - {id} - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} - -ImportArtistRow.propTypes = { - style: PropTypes.object.isRequired, - id: PropTypes.string.isRequired, - monitor: PropTypes.string.isRequired, - qualityProfileId: PropTypes.number.isRequired, - languageProfileId: PropTypes.number.isRequired, - metadataProfileId: PropTypes.number.isRequired, - albumFolder: PropTypes.bool.isRequired, - selectedArtist: PropTypes.object, - isExistingArtist: PropTypes.bool.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - queued: PropTypes.bool.isRequired, - showLanguageProfile: PropTypes.bool.isRequired, - showMetadataProfile: PropTypes.bool.isRequired, - isSelected: PropTypes.bool, - onSelectedChange: PropTypes.func.isRequired, - onInputChange: PropTypes.func.isRequired -}; - -ImportArtistRow.defaultsProps = { - items: [] -}; - -export default ImportArtistRow; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js deleted file mode 100644 index 219b82e86..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js +++ /dev/null @@ -1,89 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { queueLookupArtist, setImportArtistValue } from 'Store/Actions/importArtistActions'; -import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; -import ImportArtistRow from './ImportArtistRow'; - -function createImportArtistItemSelector() { - return createSelector( - (state, { id }) => id, - (state) => state.importArtist.items, - (id, items) => { - return _.find(items, { id }) || {}; - } - ); -} - -function createMapStateToProps() { - return createSelector( - createImportArtistItemSelector(), - createAllArtistSelector(), - (item, artist) => { - const selectedArtist = item && item.selectedArtist; - const isExistingArtist = !!selectedArtist && _.some(artist, { foreignArtistId: selectedArtist.foreignArtistId }); - - return { - ...item, - isExistingArtist - }; - } - ); -} - -const mapDispatchToProps = { - queueLookupArtist, - setImportArtistValue -}; - -class ImportArtistRowConnector extends Component { - - // - // Listeners - - onInputChange = ({ name, value }) => { - this.props.setImportArtistValue({ - id: this.props.id, - [name]: value - }); - } - - // - // Render - - render() { - // Don't show the row until we have the information we require for it. - - const { - items, - monitor, - albumFolder - } = this.props; - - if (!items || !monitor || !albumFolder == null) { - return null; - } - - return ( - - ); - } -} - -ImportArtistRowConnector.propTypes = { - rootFolderId: PropTypes.number.isRequired, - id: PropTypes.string.isRequired, - monitor: PropTypes.string, - albumFolder: PropTypes.bool, - items: PropTypes.arrayOf(PropTypes.object), - queueLookupArtist: PropTypes.func.isRequired, - setImportArtistValue: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistRowConnector); diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistSelected.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistSelected.css deleted file mode 100644 index efc6dccb3..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistSelected.css +++ /dev/null @@ -1,3 +0,0 @@ -.input { - composes: input from 'Components/Form/CheckInput.css'; -} diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js deleted file mode 100644 index 8a7de50b3..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js +++ /dev/null @@ -1,202 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import VirtualTable from 'Components/Table/VirtualTable'; -import ImportArtistHeader from './ImportArtistHeader'; -import ImportArtistRowConnector from './ImportArtistRowConnector'; - -class ImportArtistTable extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - unmappedFolders, - defaultMonitor, - defaultQualityProfileId, - defaultLanguageProfileId, - defaultMetadataProfileId, - defaultAlbumFolder, - onArtistLookup, - onSetImportArtistValue - } = this.props; - - const values = { - monitor: defaultMonitor, - qualityProfileId: defaultQualityProfileId, - languageProfileId: defaultLanguageProfileId, - metadataProfileId: defaultMetadataProfileId, - albumFolder: defaultAlbumFolder - }; - - unmappedFolders.forEach((unmappedFolder) => { - const id = unmappedFolder.name; - - onArtistLookup(id, unmappedFolder.path); - - onSetImportArtistValue({ - id, - ...values - }); - }); - } - - // This isn't great, but it's the most reliable way to ensure the items - // are checked off even if they aren't actually visible since the cells - // are virtualized. - - componentDidUpdate(prevProps) { - const { - items, - selectedState, - onSelectedChange, - onRemoveSelectedStateItem - } = this.props; - - prevProps.items.forEach((prevItem) => { - const { - id - } = prevItem; - - const item = _.find(items, { id }); - - if (!item) { - onRemoveSelectedStateItem(id); - return; - } - - const selectedArtist = item.selectedArtist; - const isSelected = selectedState[id]; - - const isExistingArtist = !!selectedArtist && - _.some(prevProps.allArtists, { foreignArtistId: selectedArtist.foreignArtistId }); - - // Props doesn't have a selected artist or - // the selected artist is an existing artist. - if ((!selectedArtist && prevItem.selectedArtist) || (isExistingArtist && !prevItem.selectedArtist)) { - onSelectedChange({ id, value: false }); - - return; - } - - // State is selected, but a artist isn't selected or - // the selected artist is an existing artist. - if (isSelected && (!selectedArtist || isExistingArtist)) { - onSelectedChange({ id, value: false }); - - return; - } - - // A artist is being selected that wasn't previously selected. - if (selectedArtist && selectedArtist !== prevItem.selectedArtist) { - onSelectedChange({ id, value: true }); - - return; - } - }); - } - - // - // Control - - rowRenderer = ({ key, rowIndex, style }) => { - const { - rootFolderId, - items, - selectedState, - showLanguageProfile, - showMetadataProfile, - onSelectedChange - } = this.props; - - const item = items[rowIndex]; - - return ( - - ); - } - - // - // Render - - render() { - const { - items, - allSelected, - allUnselected, - isSmallScreen, - contentBody, - showLanguageProfile, - showMetadataProfile, - scrollTop, - selectedState, - onSelectAllChange, - onScroll - } = this.props; - - if (!items.length) { - return null; - } - - return ( - - } - selectedState={selectedState} - onScroll={onScroll} - /> - ); - } -} - -ImportArtistTable.propTypes = { - rootFolderId: PropTypes.number.isRequired, - items: PropTypes.arrayOf(PropTypes.object), - unmappedFolders: PropTypes.arrayOf(PropTypes.object), - defaultMonitor: PropTypes.string.isRequired, - defaultQualityProfileId: PropTypes.number, - defaultLanguageProfileId: PropTypes.number, - defaultMetadataProfileId: PropTypes.number, - defaultAlbumFolder: PropTypes.bool.isRequired, - allSelected: PropTypes.bool.isRequired, - allUnselected: PropTypes.bool.isRequired, - selectedState: PropTypes.object.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - allArtists: PropTypes.arrayOf(PropTypes.object), - contentBody: PropTypes.object.isRequired, - showLanguageProfile: PropTypes.bool.isRequired, - showMetadataProfile: PropTypes.bool.isRequired, - scrollTop: PropTypes.number.isRequired, - onSelectAllChange: PropTypes.func.isRequired, - onSelectedChange: PropTypes.func.isRequired, - onRemoveSelectedStateItem: PropTypes.func.isRequired, - onArtistLookup: PropTypes.func.isRequired, - onSetImportArtistValue: PropTypes.func.isRequired, - onScroll: PropTypes.func.isRequired -}; - -export default ImportArtistTable; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTableConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTableConnector.js deleted file mode 100644 index b0a90be7c..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTableConnector.js +++ /dev/null @@ -1,44 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { queueLookupArtist, setImportArtistValue } from 'Store/Actions/importArtistActions'; -import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; -import ImportArtistTable from './ImportArtistTable'; - -function createMapStateToProps() { - return createSelector( - (state) => state.addArtist, - (state) => state.importArtist, - (state) => state.app.dimensions, - createAllArtistSelector(), - (addArtist, importArtist, dimensions, allArtists) => { - return { - defaultMonitor: addArtist.defaults.monitor, - defaultQualityProfileId: addArtist.defaults.qualityProfileId, - defaultLanguageProfileId: addArtist.defaults.languageProfileId, - defaultMetadataProfileId: addArtist.defaults.metadataProfileId, - defaultAlbumFolder: addArtist.defaults.albumFolder, - items: importArtist.items, - isSmallScreen: dimensions.isSmallScreen, - allArtists - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onArtistLookup(name, path) { - dispatch(queueLookupArtist({ - name, - path, - term: name - })); - }, - - onSetImportArtistValue(values) { - dispatch(setImportArtistValue(values)); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(ImportArtistTable); diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css deleted file mode 100644 index 263e91fda..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css +++ /dev/null @@ -1,22 +0,0 @@ -.artistNameContainer { - display: flex; - align-items: center; -} - -.artistName { - margin-right: 5px; -} - -.disambiguation { - margin-right: 5px; - color: $disabledColor; -} - -.year { - margin-left: 5px; - color: $disabledColor; -} - -.existing { - margin-left: 5px; -} diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js deleted file mode 100644 index 25d4edd16..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js +++ /dev/null @@ -1,43 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { kinds } from 'Helpers/Props'; -import Label from 'Components/Label'; -import styles from './ImportArtistName.css'; - -function ImportArtistName(props) { - const { - artistName, - disambiguation, - // year, - isExistingArtist - } = props; - - return ( -
    -
    - {artistName} -
    -
    - {disambiguation} -
    - - { - isExistingArtist && - - } -
    - ); -} - -ImportArtistName.propTypes = { - artistName: PropTypes.string.isRequired, - disambiguation: PropTypes.string, - // year: PropTypes.number.isRequired, - isExistingArtist: PropTypes.bool.isRequired -}; - -export default ImportArtistName; diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css deleted file mode 100644 index f7bc065b5..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css +++ /dev/null @@ -1,8 +0,0 @@ -.artist { - padding: 10px 20px; - width: 100%; - - &:hover { - background-color: $menuItemHoverBackgroundColor; - } -} diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.js deleted file mode 100644 index aa489f0fb..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.js +++ /dev/null @@ -1,52 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Link from 'Components/Link/Link'; -import ImportArtistName from './ImportArtistName'; -import styles from './ImportArtistSearchResult.css'; - -class ImportArtistSearchResult extends Component { - - // - // Listeners - - onPress = () => { - this.props.onPress(this.props.foreignArtistId); - } - - // - // Render - - render() { - const { - artistName, - disambiguation, - // year, - isExistingArtist - } = this.props; - - return ( - - - - ); - } -} - -ImportArtistSearchResult.propTypes = { - foreignArtistId: PropTypes.string.isRequired, - artistName: PropTypes.string.isRequired, - disambiguation: PropTypes.string, - // year: PropTypes.number.isRequired, - isExistingArtist: PropTypes.bool.isRequired, - onPress: PropTypes.func.isRequired -}; - -export default ImportArtistSearchResult; diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResultConnector.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResultConnector.js deleted file mode 100644 index cdbcc03b3..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResultConnector.js +++ /dev/null @@ -1,17 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createExistingArtistSelector from 'Store/Selectors/createExistingArtistSelector'; -import ImportArtistSearchResult from './ImportArtistSearchResult'; - -function createMapStateToProps() { - return createSelector( - createExistingArtistSelector(), - (isExistingArtist) => { - return { - isExistingArtist - }; - } - ); -} - -export default connect(createMapStateToProps)(ImportArtistSearchResult); diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css deleted file mode 100644 index c5023c00d..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css +++ /dev/null @@ -1,70 +0,0 @@ -.tether { - z-index: 2000; -} - -.button { - composes: link from 'Components/Link/Link.css'; - - position: relative; - display: flex; - align-items: center; - padding: 6px 16px; - width: 100%; - height: 35px; - border: 1px solid $inputBorderColor; - border-radius: 4px; - background-color: $white; - box-shadow: inset 0 1px 1px $inputBoxShadowColor; -} - -.loading { - display: inline-block; -} - -.warningIcon { - margin-right: 8px; -} - -.existing { - margin-left: 5px; -} - -.dropdownArrowContainer { - position: absolute; - right: 16px; -} - -.contentContainer { - margin-top: 4px; - padding: 0 8px; - width: 400px; -} - -.content { - padding: 4px; - border: 1px solid $inputBorderColor; - border-radius: 4px; - background-color: $white; -} - -.searchContainer { - display: flex; -} - -.searchIconContainer { - width: 58px; - border: 1px solid $inputBorderColor; - border-right: none; - border-radius: 4px; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - background-color: #edf1f2; - text-align: center; - line-height: 33px; -} - -.searchInput { - composes: text from 'Components/Form/TextInput.css'; - - border-radius: 0; -} diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js deleted file mode 100644 index 59a7a2746..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js +++ /dev/null @@ -1,281 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ReactDOM from 'react-dom'; -import TetherComponent from 'react-tether'; -import { icons, kinds } from 'Helpers/Props'; -import Icon from 'Components/Icon'; -import FormInputButton from 'Components/Form/FormInputButton'; -import Link from 'Components/Link/Link'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import TextInput from 'Components/Form/TextInput'; -import ImportArtistSearchResultConnector from './ImportArtistSearchResultConnector'; -import ImportArtistName from './ImportArtistName'; -import styles from './ImportArtistSelectArtist.css'; - -const tetherOptions = { - skipMoveElement: true, - constraints: [ - { - to: 'window', - attachment: 'together', - pin: true - } - ], - attachment: 'top center', - targetAttachment: 'bottom center' -}; - -class ImportArtistSelectArtist extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._artistLookupTimeout = null; - - this.state = { - term: props.id, - isOpen: false - }; - } - - // - // Control - - _setButtonRef = (ref) => { - this._buttonRef = ref; - } - - _setContentRef = (ref) => { - this._contentRef = ref; - } - - _addListener() { - window.addEventListener('click', this.onWindowClick); - } - - _removeListener() { - window.removeEventListener('click', this.onWindowClick); - } - - // - // Listeners - - onWindowClick = (event) => { - const button = ReactDOM.findDOMNode(this._buttonRef); - const content = ReactDOM.findDOMNode(this._contentRef); - - if (!button) { - return; - } - - if (!button.contains(event.target) && content && !content.contains(event.target) && this.state.isOpen) { - this.setState({ isOpen: false }); - this._removeListener(); - } - } - - onPress = () => { - if (this.state.isOpen) { - this._removeListener(); - } else { - this._addListener(); - } - - this.setState({ isOpen: !this.state.isOpen }); - } - - onSearchInputChange = ({ value }) => { - if (this._artistLookupTimeout) { - clearTimeout(this._artistLookupTimeout); - } - - this.setState({ term: value }, () => { - this._artistLookupTimeout = setTimeout(() => { - this.props.onSearchInputChange(value); - }, 200); - }); - } - - onRefreshPress = () => { - this.props.onSearchInputChange(this.state.term); - } - - onArtistSelect = (foreignArtistId) => { - this.setState({ isOpen: false }); - - this.props.onArtistSelect(foreignArtistId); - } - - // - // Render - - render() { - const { - selectedArtist, - isExistingArtist, - isFetching, - isPopulated, - error, - items, - queued, - isLookingUpArtist - } = this.props; - - const errorMessage = error && - error.responseJSON && - error.responseJSON.message; - - return ( - - - { - isLookingUpArtist && queued && !isPopulated && - - } - - { - isPopulated && selectedArtist && isExistingArtist && - - } - - { - isPopulated && selectedArtist && - - } - - { - isPopulated && !selectedArtist && -
    - - - No match found! -
    - } - - { - !isFetching && !!error && -
    - - - Search failed, please try again later. -
    - } - -
    - -
    - - - { - this.state.isOpen && -
    -
    -
    -
    - -
    - - - - - - -
    - -
    - { - items.map((item) => { - return ( - - ); - }) - } -
    -
    -
    - } -
    - ); - } -} - -ImportArtistSelectArtist.propTypes = { - id: PropTypes.string.isRequired, - selectedArtist: PropTypes.object, - isExistingArtist: PropTypes.bool.isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - queued: PropTypes.bool.isRequired, - isLookingUpArtist: PropTypes.bool.isRequired, - onSearchInputChange: PropTypes.func.isRequired, - onArtistSelect: PropTypes.func.isRequired -}; - -ImportArtistSelectArtist.defaultProps = { - isFetching: true, - isPopulated: false, - items: [], - queued: true -}; - -export default ImportArtistSelectArtist; diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js deleted file mode 100644 index 21e2bcab2..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js +++ /dev/null @@ -1,76 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { queueLookupArtist, setImportArtistValue } from 'Store/Actions/importArtistActions'; -import createImportArtistItemSelector from 'Store/Selectors/createImportArtistItemSelector'; -import ImportArtistSelectArtist from './ImportArtistSelectArtist'; - -function createMapStateToProps() { - return createSelector( - (state) => state.importArtist.isLookingUpArtist, - createImportArtistItemSelector(), - (isLookingUpArtist, item) => { - return { - isLookingUpArtist, - ...item - }; - } - ); -} - -const mapDispatchToProps = { - queueLookupArtist, - setImportArtistValue -}; - -class ImportArtistSelectArtistConnector extends Component { - - // - // Listeners - - onSearchInputChange = (term) => { - this.props.queueLookupArtist({ - name: this.props.id, - term, - topOfQueue: true - }); - } - - onArtistSelect = (foreignArtistId) => { - const { - id, - items - } = this.props; - - this.props.setImportArtistValue({ - id, - selectedArtist: _.find(items, { foreignArtistId }) - }); - } - - // - // Render - - render() { - return ( - - ); - } -} - -ImportArtistSelectArtistConnector.propTypes = { - id: PropTypes.string.isRequired, - items: PropTypes.arrayOf(PropTypes.object), - selectedArtist: PropTypes.object, - isSelected: PropTypes.bool, - queueLookupArtist: PropTypes.func.isRequired, - setImportArtistValue: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistSelectArtistConnector); diff --git a/frontend/src/AddArtist/ImportArtist/ImportArtist.js b/frontend/src/AddArtist/ImportArtist/ImportArtist.js deleted file mode 100644 index ce5ec27ee..000000000 --- a/frontend/src/AddArtist/ImportArtist/ImportArtist.js +++ /dev/null @@ -1,30 +0,0 @@ -import React, { Component } from 'react'; -import { Route } from 'react-router-dom'; -import Switch from 'Components/Router/Switch'; -import ImportArtistSelectFolderConnector from 'AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector'; -import ImportArtistConnector from 'AddArtist/ImportArtist/Import/ImportArtistConnector'; - -class ImportArtist extends Component { - - // - // Render - - render() { - return ( - - - - - - ); - } -} - -export default ImportArtist; diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.css b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.css deleted file mode 100644 index d9c5ccb01..000000000 --- a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.css +++ /dev/null @@ -1,18 +0,0 @@ -.link { - composes: link from 'Components/Link/Link.css'; - - display: block; -} - -.freeSpace, -.unmappedFolders { - composes: cell from 'Components/Table/Cells/TableRowCell.css'; - - width: 150px; -} - -.actions { - composes: cell from 'Components/Table/Cells/TableRowCell.css'; - - width: 45px; -} diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.js b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.js deleted file mode 100644 index 8a4e7b982..000000000 --- a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.js +++ /dev/null @@ -1,64 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import formatBytes from 'Utilities/Number/formatBytes'; -import { icons } from 'Helpers/Props'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import TableRow from 'Components/Table/TableRow'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import styles from './ImportArtistRootFolderRow.css'; - -function ImportArtistRootFolderRow(props) { - const { - id, - path, - freeSpace, - unmappedFolders, - onDeletePress - } = props; - - const unmappedFoldersCount = unmappedFolders.length || '-'; - - return ( - - - - {path} - - - - - {formatBytes(freeSpace) || '-'} - - - - {unmappedFoldersCount} - - - - - - - ); -} - -ImportArtistRootFolderRow.propTypes = { - id: PropTypes.number.isRequired, - path: PropTypes.string.isRequired, - freeSpace: PropTypes.number.isRequired, - unmappedFolders: PropTypes.arrayOf(PropTypes.object).isRequired, - onDeletePress: PropTypes.func.isRequired -}; - -ImportArtistRootFolderRow.defaultProps = { - freeSpace: 0, - unmappedFolders: [] -}; - -export default ImportArtistRootFolderRow; diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRowConnector.js b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRowConnector.js deleted file mode 100644 index 194e6e37c..000000000 --- a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRowConnector.js +++ /dev/null @@ -1,48 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { deleteRootFolder } from 'Store/Actions/rootFolderActions'; -import ImportArtistRootFolderRow from './ImportArtistRootFolderRow'; - -function createMapStateToProps() { - return createSelector( - () => { - return { - }; - } - ); -} - -const mapDispatchToProps = { - deleteRootFolder -}; - -class ImportArtistRootFolderRowConnector extends Component { - - // - // Listeners - - onDeletePress = () => { - this.props.deleteRootFolder({ id: this.props.id }); - } - - // - // Render - - render() { - return ( - - ); - } -} - -ImportArtistRootFolderRowConnector.propTypes = { - id: PropTypes.number.isRequired, - deleteRootFolder: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistRootFolderRowConnector); diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.css b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.css deleted file mode 100644 index 030da96fb..000000000 --- a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.css +++ /dev/null @@ -1,32 +0,0 @@ -.header { - margin-bottom: 40px; - text-align: center; - font-weight: 300; - font-size: 36px; -} - -.tips { - font-size: 20px; -} - -.tip { - font-size: $defaultFontSize; -} - -.code { - font-size: 12px; - font-family: $monoSpaceFontFamily; -} - -.recentFolders { - margin-top: 40px; -} - -.startImport { - margin-top: 40px; - text-align: center; -} - -.importButtonIcon { - margin-right: 8px; -} diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js deleted file mode 100644 index 19cbe682c..000000000 --- a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js +++ /dev/null @@ -1,185 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { icons, kinds, sizes } from 'Helpers/Props'; -import Button from 'Components/Link/Button'; -import FieldSet from 'Components/FieldSet'; -import Icon from 'Components/Icon'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import ImportArtistRootFolderRowConnector from './ImportArtistRootFolderRowConnector'; -import styles from './ImportArtistSelectFolder.css'; - -const rootFolderColumns = [ - { - name: 'path', - label: 'Path', - isVisible: true - }, - { - name: 'freeSpace', - label: 'Free Space', - isVisible: true - }, - { - name: 'unmappedFolders', - label: 'Unmapped Folders', - isVisible: true - }, - { - name: 'actions', - isVisible: true - } -]; - -class ImportArtistSelectFolder extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isAddNewRootFolderModalOpen: false - }; - } - - // - // Lifecycle - - onAddNewRootFolderPress = () => { - this.setState({ isAddNewRootFolderModalOpen: true }); - } - - onNewRootFolderSelect = ({ value }) => { - this.props.onNewRootFolderSelect(value); - } - - onAddRootFolderModalClose = () => { - this.setState({ isAddNewRootFolderModalOpen: false }); - } - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - items - } = this.props; - - return ( - - - { - isFetching && !isPopulated && - - } - - { - !isFetching && !!error && -
    Unable to load root folders
    - } - - { - !error && isPopulated && -
    -
    - Import artist(s) you already have -
    - -
    - Some tips to ensure the import goes smoothly: -
      -
    • - Point Lidarr to the folder containing all of your music not a specific artist. eg. "\music\" and not "\music\alien ant farm\" -
    • -
    -
    - - { - items.length > 0 ? -
    -
    -
- - { - items.map((rootFolder) => { - return ( - - ); - }) - } - -
- - - -
: - -
- -
- } - - - - } -
-
- ); - } -} - -ImportArtistSelectFolder.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - onNewRootFolderSelect: PropTypes.func.isRequired, - onDeleteRootFolderPress: PropTypes.func.isRequired -}; - -export default ImportArtistSelectFolder; diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector.js b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector.js deleted file mode 100644 index 10d010903..000000000 --- a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector.js +++ /dev/null @@ -1,86 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { push } from 'react-router-redux'; -import { fetchRootFolders, addRootFolder, deleteRootFolder } from 'Store/Actions/rootFolderActions'; -import ImportArtistSelectFolder from './ImportArtistSelectFolder'; - -function createMapStateToProps() { - return createSelector( - (state) => state.rootFolders, - (rootFolders) => { - return rootFolders; - } - ); -} - -const mapDispatchToProps = { - fetchRootFolders, - addRootFolder, - deleteRootFolder, - push -}; - -class ImportArtistSelectFolderConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.fetchRootFolders(); - } - - componentDidUpdate(prevProps) { - const { - items, - isSaving, - saveError - } = this.props; - - if (prevProps.isSaving && !isSaving && !saveError) { - const newRootFolders = _.differenceBy(items, prevProps.items, (item) => item.id); - - if (newRootFolders.length === 1) { - this.props.push(`${window.Lidarr.urlBase}/add/import/${newRootFolders[0].id}`); - } - } - } - - // - // Listeners - - onNewRootFolderSelect = (path) => { - this.props.addRootFolder({ path }); - } - - onDeleteRootFolderPress = (id) => { - this.props.deleteRootFolder({ id }); - } - - // - // Render - - render() { - return ( - - ); - } -} - -ImportArtistSelectFolderConnector.propTypes = { - isSaving: PropTypes.bool.isRequired, - saveError: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - fetchRootFolders: PropTypes.func.isRequired, - addRootFolder: PropTypes.func.isRequired, - deleteRootFolder: PropTypes.func.isRequired, - push: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistSelectFolderConnector); diff --git a/frontend/src/Album/Album.ts b/frontend/src/Album/Album.ts new file mode 100644 index 000000000..86f1ed5fe --- /dev/null +++ b/frontend/src/Album/Album.ts @@ -0,0 +1,27 @@ +import ModelBase from 'App/ModelBase'; +import Artist from 'Artist/Artist'; + +export interface Statistics { + trackCount: number; + trackFileCount: number; + percentOfTracks: number; + sizeOnDisk: number; + totalTrackCount: number; +} + +interface Album extends ModelBase { + artistId: number; + artist: Artist; + foreignAlbumId: string; + title: string; + overview: string; + disambiguation?: string; + albumType: string; + monitored: boolean; + releaseDate: string; + statistics: Statistics; + lastSearchTime?: string; + isSaving?: boolean; +} + +export default Album; diff --git a/frontend/src/Album/AlbumCover.js b/frontend/src/Album/AlbumCover.js index 657cc038a..538fa5db8 100644 --- a/frontend/src/Album/AlbumCover.js +++ b/frontend/src/Album/AlbumCover.js @@ -1,175 +1,25 @@ -import _ from 'lodash'; import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import LazyLoad from 'react-lazyload'; +import React from 'react'; +import ArtistImage from 'Artist/ArtistImage'; const coverPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPcAAAD3AgMAAAC84irAAAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+EJEBIzDdm9OfoAAAbkSURBVGje7Zq9b9s4FMBZFgUkBR27C3cw0MromL1jxwyVZASB67G4qWPgoSAyBdm9CwECKCp8nbIccGj/Ce/BTUb3Lh3aI997pCjnTnyyt0JcIif5+ZHvPZLvQ0KMYxzjGMc4xjGOcYxjHOP4JUfSfP7RVPvSH3MYX/eC5aecxne1v+w95WebFs/rwVO/8+h8PnT6t3ln/DFQuJ06/SyHiX9pxa7o5/lewkuLDxLvhM8tPki8g07dU8Gnj5zGlw7P79n4pDVYi8/YuHO4n03z0z6XXDom4G3TXDdN840+LobN/W1Ty2slHD8bNvevlUgutLmTj4NmT3pf6mMGcJGth+gefaZsDCjB2Wj65wN8ZmnAGnE6eFieI1FvcEISLjIUr9hm+w7PFeHiE9t0E7dyIatE48odXTPu0j/A3BMnXf7NXDxudTxbE2VxMWVu+sfwf3i1ZMLiaQLf+iWIP4VtjtTzFhc35vfveZrb4nPt4R95ulu1cxeVh8Psw7rzbgWp8dWHyr83WJpbgjypjS5XeZnqRxmJNUd3MS1d6ue/tOn0WuayNd2CoTlaeqwnIVeOgcWHdHdMS9cSN1vCy3bxZwzFm6VL7QA14WTudVj1sFvf4ReZNSCO0IvwngXFV3hkFcriuPokrPrYbYxjVAHiZ24zLYIeP7/E4xZUgHiZWt29D9ptGemHR7mPo9B10HLGbucRfs/Ww2f2CD4L2u0+wofKwwvrd0XoqCmr38CAZa1d58LesEpvgqtN4MCR1mVj2nZWOiweVB/CAXuyi59Y1auA2eekg6Xw8Tfm013A8LFV8mYXL61ZF4Hb8Zx8d9vBtbdG7s99XvOOZlF38QVtmlkAv0ffxTOjxU/o5p8FvKbSszw2ik87+Iz23Lwf134RiWf2tG3xN2T4oh8vDO4U33z+5qnefFnR77OA2wheh2WfbJBHeI/XgtNJEaHdtJNrvPn8E8eV/kW/2xn8FDc77LemOyq4J1XvSbds7SZ3cAV+86UXP283TGaFUk4ZwmNyugne8FaqxdHtFkH8GNewg2cc3PjsM7CbbNdMwQJ47aL3mP5H308ar5XOn2nUwpx+4hrx/z+qn5DBNqD4rMUpWACnPwnhkfa9SnZwvX1MnHLVi08cPle+0wBuAsykd8dO0KkS9L0dPCO37MVLxJc6nPHdTeNT/ZeLDQN/DEFpBzc33Bfckhx8K1q7IS5vuPgjbTf5AL97zcALxFUHN76QrF7heTHru54RN3bbxTeEn4Xx04f4NOfhSuPLncmnQk3z1yLlSE8fabtFHVyZyIQlXes8zrdSJR5ea7k3+asUooXg2mO4oDprT/XdHpROhouL/8A3edBw5DYxBhYdn08Q53jd0elDfApHbHjL6Hk/pvvNd1rEWdLl9iG+hpMgiMMdVEM64B8X5nq6ZBwX5rCSeK/4uInJROiwetLi0jtpG0yJBPOkTVQXryEPKqMQbq6JeyUTvUOkilq/EVGmo5NIpP3XRIzhXIafrjzF30JUIqecKxIjOpF6il9jbHTLxjs3rN5voPH+GxbDA1m7GrM9a4zdTigdCUUXD2MSSEAXQRxDo2QHl2iwV+h7gchqLrLrhmKxH/Z6nqLUQD5AYSHWAEwk+Z1Ck1vEAmEhBaVtufDtj8Zmv6U+PQNBqbDf/szVR5XNvQteSAzRyeQhzgnIKR2Invq43gQb4+oRaJCTTcRd6RkzGXlJQe3vDq8gsDB2S0QaSoViwKNW9Sh9zUzEMA2MWtU7nJUGYhIa4bnjcLthgkkopMAGj3dxXgoMCbg+laTFL8luSn9pFkrAMf031cmVJz0jXzsKFm6OSfVqYnEILPKZDjeicPFhQoaHbMhKX+NmZ5Q+ntr8n5obhGPVKlx48cs+FteKP3MlswWv6CSPHK4Dmntm0ckreW0snmxKbsnLFdyo4mrwjLYJo+Dmyn0k3uDTEpMRTrnPKza+IHy9wGSEU2yMvSrvHeJ/Qt2UV+p0hVacvsah0psKXqEVy7y2tPu3xhM1oMxLReY00tAlJG9JFZktzCwyU4lbuqQ7U22VN1zi9gvsIP05PjAL7H55H/C6rREzyvu41bbS4VXb1OV0FLG1YVsa1J1gtzaosVJbHO3Gb6z4bR2H89s61FRqCIcgL+E3lfyWlsaN3eR6QDP0pSdeKqOEZjOgoda285SUl5W+Jga181wz0WQFF2poM7FtZTZKXlXZ0Fam10htroY3Ug9s43pN5OJ2jyZy28Iu1nu0sNsGenGzRwO9bd8Xd/u0793LA8Vmn5cHnPhiH+Gt+HIv4Ye+tnHoSyMHvrJy6Aszh76uc+DLQuLQV5XGMY5xjGMc4xjHOMYxjnH80uNfW99BeoyzJCoAAAAASUVORK5CYII='; -function findCover(images) { - return _.find(images, { coverType: 'cover' }); -} - -function getCoverUrl(cover, size) { - if (cover) { - if (cover.url.contains('lastWrite=') || (/^https?:/).test(cover.url)) { - // Remove protocol - let url = cover.url.replace(/^https?:/, ''); - url = url.replace('cover.jpg', `cover-${size}.jpg`); - - return url; - } - } -} - -class AlbumCover extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - const pixelRatio = Math.floor(window.devicePixelRatio); - - const { - images, - size - } = props; - - const cover = findCover(images); - - this.state = { - pixelRatio, - cover, - coverUrl: getCoverUrl(cover, pixelRatio * size), - isLoaded: false, - hasError: false - }; - } - - componentDidUpdate(prevProps) { - const { - images, - size - } = this.props; - - const { - cover, - pixelRatio - } = this.state; - - const nextCover = findCover(images); - - if (nextCover && (!cover || nextCover.url !== cover.url)) { - this.setState({ - cover: nextCover, - coverUrl: getCoverUrl(nextCover, pixelRatio * size), - hasError: false, - isLoaded: true - }); - } - - // The cover could not be loaded.. - if (!nextCover && (this.props !== prevProps)) { - this.setState({ - cover: undefined, - coverUrl: coverPlaceholder, - hasError: true - }); - } - } - - // - // Listeners - - onError = () => { - this.setState({ hasError: true }); - } - - onLoad = () => { - this.setState({ - isLoaded: true, - hasError: false - }); - } - - // - // Render - - render() { - const { - className, - style, - size, - lazy, - overflow - } = this.props; - - const { - coverUrl, - hasError, - isLoaded - } = this.state; - - if (hasError || !coverUrl) { - return ( - - ); - } - - if (lazy) { - return ( - - } - > - - - ); - } - - return ( - - ); - } +function AlbumCover(props) { + return ( + + ); } AlbumCover.propTypes = { - className: PropTypes.string, - style: PropTypes.object, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - size: PropTypes.number.isRequired, - lazy: PropTypes.bool.isRequired, - overflow: PropTypes.bool.isRequired + size: PropTypes.number.isRequired }; AlbumCover.defaultProps = { - size: 250, - lazy: true, - overflow: false + size: 250 }; export default AlbumCover; diff --git a/frontend/src/Album/AlbumFormats.js b/frontend/src/Album/AlbumFormats.js new file mode 100644 index 000000000..1591fa714 --- /dev/null +++ b/frontend/src/Album/AlbumFormats.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Label from 'Components/Label'; +import { kinds } from 'Helpers/Props'; + +function AlbumFormats({ formats }) { + return ( +
+ { + formats.map((format) => { + return ( + + ); + }) + } +
+ ); +} + +AlbumFormats.propTypes = { + formats: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +AlbumFormats.defaultProps = { + formats: [] +}; + +export default AlbumFormats; diff --git a/frontend/src/Album/AlbumSearchCell.css b/frontend/src/Album/AlbumSearchCell.css index 651683153..ba099e8c0 100644 --- a/frontend/src/Album/AlbumSearchCell.css +++ b/frontend/src/Album/AlbumSearchCell.css @@ -1,5 +1,5 @@ .AlbumSearchCell { - composes: cell from 'Components/Table/Cells/TableRowCell.css'; + composes: cell from '~Components/Table/Cells/TableRowCell.css'; width: 70px; white-space: nowrap; diff --git a/frontend/src/Album/AlbumSearchCell.css.d.ts b/frontend/src/Album/AlbumSearchCell.css.d.ts new file mode 100644 index 000000000..154740212 --- /dev/null +++ b/frontend/src/Album/AlbumSearchCell.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'AlbumSearchCell': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Album/AlbumSearchCell.js b/frontend/src/Album/AlbumSearchCell.js index 83b7c2f23..f9ca44800 100644 --- a/frontend/src/Album/AlbumSearchCell.js +++ b/frontend/src/Album/AlbumSearchCell.js @@ -1,10 +1,11 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { icons } from 'Helpers/Props'; import IconButton from 'Components/Link/IconButton'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import InteractiveAlbumSearchModal from './Search/InteractiveAlbumSearchModal'; +import { icons } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import AlbumInteractiveSearchModalConnector from './Search/AlbumInteractiveSearchModalConnector'; import styles from './AlbumSearchCell.css'; class AlbumSearchCell extends Component { @@ -25,11 +26,11 @@ class AlbumSearchCell extends Component { onManualSearchPress = () => { this.setState({ isDetailsModalOpen: true }); - } + }; onDetailsModalClose = () => { this.setState({ isDetailsModalOpen: false }); - } + }; // // Render @@ -37,6 +38,7 @@ class AlbumSearchCell extends Component { render() { const { albumId, + albumTitle, isSearching, onSearchPress, ...otherProps @@ -48,16 +50,19 @@ class AlbumSearchCell extends Component { name={icons.SEARCH} isSpinning={isSearching} onPress={onSearchPress} + title={translate('AutomaticSearch')} /> - diff --git a/frontend/src/Album/AlbumSearchCellConnector.js b/frontend/src/Album/AlbumSearchCellConnector.js index f5f1f7693..41360efeb 100644 --- a/frontend/src/Album/AlbumSearchCellConnector.js +++ b/frontend/src/Album/AlbumSearchCellConnector.js @@ -1,10 +1,10 @@ -import _ from 'lodash'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; +import * as commandNames from 'Commands/commandNames'; +import { executeCommand } from 'Store/Actions/commandActions'; import createArtistSelector from 'Store/Selectors/createArtistSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; -import { executeCommand } from 'Store/Actions/commandActions'; -import * as commandNames from 'Commands/commandNames'; +import { isCommandExecuting } from 'Utilities/Command'; import AlbumSearchCell from './AlbumSearchCell'; function createMapStateToProps() { @@ -13,14 +13,17 @@ function createMapStateToProps() { createArtistSelector(), createCommandsSelector(), (albumId, artist, commands) => { - const isSearching = _.some(commands, (command) => { + const isSearching = commands.some((command) => { const albumSearch = command.name === commandNames.ALBUM_SEARCH; if (!albumSearch) { return false; } - return command.body.albumIds.indexOf(albumId) > -1; + return ( + isCommandExecuting(command) && + command.body.albumIds.indexOf(albumId) > -1 + ); }); return { diff --git a/frontend/src/Album/AlbumTitleLink.css b/frontend/src/Album/AlbumTitleLink.css index 6022be8a4..7fd85c836 100644 --- a/frontend/src/Album/AlbumTitleLink.css +++ b/frontend/src/Album/AlbumTitleLink.css @@ -1,8 +1,8 @@ .link { - composes: link from 'Components/Link/Link.css'; + composes: link from '~Components/Link/Link.css'; &:hover { - color: $linkHoverColor; + color: var(--linkHoverColor); text-decoration: underline; } } diff --git a/frontend/src/Album/AlbumTitleLink.js b/frontend/src/Album/AlbumTitleLink.js index 42945430b..e55fadfc0 100644 --- a/frontend/src/Album/AlbumTitleLink.js +++ b/frontend/src/Album/AlbumTitleLink.js @@ -2,19 +2,21 @@ import PropTypes from 'prop-types'; import React from 'react'; import Link from 'Components/Link/Link'; -function AlbumTitleLink({ foreignAlbumId, title }) { +function AlbumTitleLink({ foreignAlbumId, title, disambiguation }) { const link = `/album/${foreignAlbumId}`; + const albumTitle = `${title}${disambiguation ? ` (${disambiguation})` : ''}`; return ( - - {title} + + {albumTitle} ); } AlbumTitleLink.propTypes = { foreignAlbumId: PropTypes.string.isRequired, - title: PropTypes.string.isRequired + title: PropTypes.string.isRequired, + disambiguation: PropTypes.string }; export default AlbumTitleLink; diff --git a/frontend/src/Album/Delete/DeleteAlbumModal.js b/frontend/src/Album/Delete/DeleteAlbumModal.js new file mode 100644 index 000000000..303010ca3 --- /dev/null +++ b/frontend/src/Album/Delete/DeleteAlbumModal.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import DeleteAlbumModalContentConnector from './DeleteAlbumModalContentConnector'; + +function DeleteAlbumModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +DeleteAlbumModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default DeleteAlbumModal; diff --git a/frontend/src/Album/Delete/DeleteAlbumModalContent.css b/frontend/src/Album/Delete/DeleteAlbumModalContent.css new file mode 100644 index 000000000..df8e4822e --- /dev/null +++ b/frontend/src/Album/Delete/DeleteAlbumModalContent.css @@ -0,0 +1,12 @@ +.pathContainer { + margin-bottom: 20px; +} + +.pathIcon { + margin-right: 8px; +} + +.deleteFilesMessage { + margin-top: 20px; + color: var(--dangerColor); +} diff --git a/frontend/src/Album/Delete/DeleteAlbumModalContent.css.d.ts b/frontend/src/Album/Delete/DeleteAlbumModalContent.css.d.ts new file mode 100644 index 000000000..e55686abe --- /dev/null +++ b/frontend/src/Album/Delete/DeleteAlbumModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'deleteFilesMessage': string; + 'pathContainer': string; + 'pathIcon': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Album/Delete/DeleteAlbumModalContent.js b/frontend/src/Album/Delete/DeleteAlbumModalContent.js new file mode 100644 index 000000000..28505ea75 --- /dev/null +++ b/frontend/src/Album/Delete/DeleteAlbumModalContent.js @@ -0,0 +1,164 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds } from 'Helpers/Props'; +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import styles from './DeleteAlbumModalContent.css'; + +class DeleteAlbumModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + deleteFiles: false, + addImportListExclusion: true + }; + } + + // + // Listeners + + onDeleteFilesChange = ({ value }) => { + this.setState({ deleteFiles: value }); + }; + + onAddImportListExclusionChange = ({ value }) => { + this.setState({ addImportListExclusion: value }); + }; + + onDeleteAlbumConfirmed = () => { + const deleteFiles = this.state.deleteFiles; + const addImportListExclusion = this.state.addImportListExclusion; + + this.setState({ deleteFiles: false }); + this.setState({ addImportListExclusion: false }); + this.props.onDeletePress(deleteFiles, addImportListExclusion); + }; + + // + // Render + + render() { + const { + title, + statistics = {}, + onModalClose + } = this.props; + + const { + trackFileCount = 0, + sizeOnDisk = 0 + } = statistics; + + const deleteFiles = this.state.deleteFiles; + const addImportListExclusion = this.state.addImportListExclusion; + + const deleteFilesLabel = `Delete ${trackFileCount} Track Files`; + const deleteFilesHelpText = 'Delete the track files'; + + return ( + + + Delete - {title} + + + + + + {deleteFilesLabel} + + + + + + + {translate('AddListExclusion')} + + + + + + { + !addImportListExclusion && +
+
+ {translate('IfYouDontAddAnImportListExclusionAndTheArtistHasAMetadataProfileOtherThanNoneThenThisAlbumMayBeReaddedDuringTheNextArtistRefresh')} +
+
+ } + + { + deleteFiles && +
+
+ {translate('TheAlbumsFilesWillBeDeleted')} +
+ + { + !!trackFileCount && +
{trackFileCount} track files totaling {formatBytes(sizeOnDisk)}
+ } +
+ } + +
+ + + + + + +
+ ); + } +} + +DeleteAlbumModalContent.propTypes = { + title: PropTypes.string.isRequired, + statistics: PropTypes.object.isRequired, + onDeletePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +DeleteAlbumModalContent.defaultProps = { + statistics: { + trackFileCount: 0 + } +}; + +export default DeleteAlbumModalContent; diff --git a/frontend/src/Album/Delete/DeleteAlbumModalContentConnector.js b/frontend/src/Album/Delete/DeleteAlbumModalContentConnector.js new file mode 100644 index 000000000..45ae4ceb3 --- /dev/null +++ b/frontend/src/Album/Delete/DeleteAlbumModalContentConnector.js @@ -0,0 +1,62 @@ +import { push } from 'connected-react-router'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { deleteAlbum } from 'Store/Actions/albumActions'; +import createAlbumSelector from 'Store/Selectors/createAlbumSelector'; +import DeleteAlbumModalContent from './DeleteAlbumModalContent'; + +function createMapStateToProps() { + return createSelector( + createAlbumSelector(), + (album) => { + return album; + } + ); +} + +const mapDispatchToProps = { + push, + deleteAlbum +}; + +class DeleteAlbumModalContentConnector extends Component { + + // + // Listeners + + onDeletePress = (deleteFiles, addImportListExclusion) => { + this.props.deleteAlbum({ + id: this.props.albumId, + deleteFiles, + addImportListExclusion + }); + + this.props.onModalClose(true); + + this.props.push(`${window.Lidarr.urlBase}/artist/${this.props.foreignArtistId}`); + }; + + // + // Render + + render() { + return ( + + ); + } +} + +DeleteAlbumModalContentConnector.propTypes = { + albumId: PropTypes.number.isRequired, + foreignArtistId: PropTypes.string.isRequired, + push: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + deleteAlbum: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DeleteAlbumModalContentConnector); diff --git a/frontend/src/Album/Details/AlbumDetails.css b/frontend/src/Album/Details/AlbumDetails.css index 8b5f5c770..a676ae574 100644 --- a/frontend/src/Album/Details/AlbumDetails.css +++ b/frontend/src/Album/Details/AlbumDetails.css @@ -20,7 +20,7 @@ position: absolute; width: 100%; height: 100%; - background: $black; + background: var(--black); opacity: 0.7; } @@ -29,14 +29,7 @@ padding: 30px; width: 100%; height: 100%; - color: $white; -} - -.logo { - flex-shrink: 0; - margin-right: 35px; - width: 250px; - height: 97px; + color: var(--white); } .cover { @@ -54,6 +47,7 @@ } .titleRow { + position: relative; display: flex; justify-content: space-between; flex: 0 0 auto; @@ -61,50 +55,76 @@ .titleContainer { display: flex; - justify-content: space-between; + margin-bottom: 5px; } .title { - margin-bottom: 5px; font-weight: 300; font-size: 50px; line-height: 50px; } +.toggleMonitoredContainer { + align-self: center; + margin-right: 10px; +} + +.monitorToggleButton { + composes: toggleButton from '~Components/MonitorToggleButton.css'; + + width: 40px; + + &:hover { + color: var(--iconButtonHoverLightColor); + } +} + .alternateTitlesIconContainer { + align-self: flex-end; margin-left: 20px; - line-height: 50px; } -.artistNavigationButtons { - white-space: no-wrap; -} - -.artistNavigationButton { - composes: button from 'Components/Link/IconButton.css'; - - margin-left: 5px; - color: #e1e2e3; +.albumNavigationButtons { + position: absolute; + right: 0; white-space: nowrap; } +.albumNavigationButton { + composes: button from '~Components/Link/IconButton.css'; + + margin-left: 5px; + width: 30px; + color: #e1e2e3; + white-space: nowrap; + + &:hover { + color: var(--iconButtonHoverLightColor); + } +} + .details { + margin-bottom: 8px; font-weight: 300; font-size: 20px; } -.runtime { +.duration { margin-right: 15px; } .detailsLabel { - composes: label from 'Components/Label.css'; + composes: label from '~Components/Label.css'; margin: 5px 10px 5px 0; } +.releaseDate, .sizeOnDisk, +.albumType, +.secondaryTypes, .qualityProfileName, +.links, .tags { margin-left: 8px; font-weight: 300; @@ -113,7 +133,9 @@ .overview { flex: 1 0 auto; + margin-top: 8px; min-height: 0; + font-size: $intermediateFontSize; } .contentContainer { @@ -128,6 +150,12 @@ .headerContent { padding: 15px; } + + .title { + font-weight: 300; + font-size: 30px; + line-height: 30px; + } } @media only screen and (max-width: $breakpointLarge) { diff --git a/frontend/src/Album/Details/AlbumDetails.css.d.ts b/frontend/src/Album/Details/AlbumDetails.css.d.ts new file mode 100644 index 000000000..1d14a0ccf --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetails.css.d.ts @@ -0,0 +1,33 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'albumNavigationButton': string; + 'albumNavigationButtons': string; + 'albumType': string; + 'alternateTitlesIconContainer': string; + 'backdrop': string; + 'backdropOverlay': string; + 'contentContainer': string; + 'cover': string; + 'details': string; + 'detailsLabel': string; + 'duration': string; + 'header': string; + 'headerContent': string; + 'info': string; + 'innerContentBody': string; + 'links': string; + 'monitorToggleButton': string; + 'overview': string; + 'qualityProfileName': string; + 'releaseDate': string; + 'secondaryTypes': string; + 'sizeOnDisk': string; + 'tags': string; + 'title': string; + 'titleContainer': string; + 'titleRow': string; + 'toggleMonitoredContainer': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Album/Details/AlbumDetails.js b/frontend/src/Album/Details/AlbumDetails.js index 25b196dba..fe007e168 100644 --- a/frontend/src/Album/Details/AlbumDetails.js +++ b/frontend/src/Album/Details/AlbumDetails.js @@ -2,37 +2,67 @@ import _ from 'lodash'; import moment from 'moment'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import formatBytes from 'Utilities/Number/formatBytes'; -import selectAll from 'Utilities/Table/selectAll'; -import toggleSelected from 'Utilities/Table/toggleSelected'; -import { align, icons, sizes } from 'Helpers/Props'; +import TextTruncate from 'react-text-truncate'; +import AlbumCover from 'Album/AlbumCover'; +import DeleteAlbumModal from 'Album/Delete/DeleteAlbumModal'; +import EditAlbumModalConnector from 'Album/Edit/EditAlbumModalConnector'; +import AlbumInteractiveSearchModalConnector from 'Album/Search/AlbumInteractiveSearchModalConnector'; +import ArtistGenres from 'Artist/Details/ArtistGenres'; +import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal'; +import Alert from 'Components/Alert'; import HeartRating from 'Components/HeartRating'; import Icon from 'Components/Icon'; -import IconButton from 'Components/Link/IconButton'; import Label from 'Components/Label'; -import AlbumCover from 'Album/AlbumCover'; -import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; -import EditAlbumModalConnector from 'Album/Edit/EditAlbumModalConnector'; +import IconButton from 'Components/Link/IconButton'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; import PageContent from 'Components/Page/PageContent'; -import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageContentBody from 'Components/Page/PageContentBody'; import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; -import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; -import AlbumDetailsMediumConnector from './AlbumDetailsMediumConnector'; -import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal'; -import InteractiveAlbumSearchModal from 'Album/Search/InteractiveAlbumSearchModal'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props'; +import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; +import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector'; +import fonts from 'Styles/Variables/fonts'; import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal'; - +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import AlbumDetailsLinks from './AlbumDetailsLinks'; +import AlbumDetailsMediumConnector from './AlbumDetailsMediumConnector'; import styles from './AlbumDetails.css'; +const intermediateFontSize = parseInt(fonts.intermediateFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + function getFanartUrl(images) { - const fanartImage = _.find(images, { coverType: 'fanart' }); - if (fanartImage) { - // Remove protocol - return fanartImage.url.replace(/^https?:/, ''); + return _.find(images, { coverType: 'fanart' })?.url; +} + +function formatDuration(timeSpan) { + const duration = moment.duration(timeSpan); + const hours = duration.get('hours'); + const minutes = duration.get('minutes'); + let hoursText = 'Hours'; + let minText = 'Minutes'; + + if (minutes === 1) { + minText = 'Minute'; } + + if (hours === 0) { + return `${minutes} ${minText}`; + } + + if (hours === 1) { + hoursText = 'Hour'; + } + + return `${hours} ${hoursText} ${minutes} ${minText}`; } function getExpandedState(newState) { @@ -53,10 +83,12 @@ class AlbumDetails extends Component { this.state = { isOrganizeModalOpen: false, + isRetagModalOpen: false, isArtistHistoryModalOpen: false, isInteractiveSearchModalOpen: false, isManageTracksOpen: false, isEditAlbumModalOpen: false, + isDeleteAlbumModalOpen: false, allExpanded: false, allCollapsed: false, expandedState: {} @@ -68,43 +100,62 @@ class AlbumDetails extends Component { onOrganizePress = () => { this.setState({ isOrganizeModalOpen: true }); - } + }; onOrganizeModalClose = () => { this.setState({ isOrganizeModalOpen: false }); - } + }; + + onRetagPress = () => { + this.setState({ isRetagModalOpen: true }); + }; + + onRetagModalClose = () => { + this.setState({ isRetagModalOpen: false }); + }; onEditAlbumPress = () => { this.setState({ isEditAlbumModalOpen: true }); - } + }; onEditAlbumModalClose = () => { this.setState({ isEditAlbumModalOpen: false }); - } + }; + + onDeleteAlbumPress = () => { + this.setState({ + isEditAlbumModalOpen: false, + isDeleteAlbumModalOpen: true + }); + }; + + onDeleteAlbumModalClose = () => { + this.setState({ isDeleteAlbumModalOpen: false }); + }; onManageTracksPress = () => { this.setState({ isManageTracksOpen: true }); - } + }; onManageTracksModalClose = () => { this.setState({ isManageTracksOpen: false }); - } + }; onInteractiveSearchPress = () => { this.setState({ isInteractiveSearchModalOpen: true }); - } + }; onInteractiveSearchModalClose = () => { this.setState({ isInteractiveSearchModalOpen: false }); - } + }; onArtistHistoryPress = () => { this.setState({ isArtistHistoryModalOpen: true }); - } + }; onArtistHistoryModalClose = () => { this.setState({ isArtistHistoryModalOpen: false }); - } + }; onExpandAllPress = () => { const { @@ -113,7 +164,7 @@ class AlbumDetails extends Component { } = this.state; this.setState(getExpandedState(selectAll(expandedState, !allExpanded))); - } + }; onExpandPress = (albumId, isExpanded) => { this.setState((state) => { @@ -127,7 +178,7 @@ class AlbumDetails extends Component { return getExpandedState(newState); }); - } + }; // // Render @@ -135,38 +186,65 @@ class AlbumDetails extends Component { render() { const { id, + foreignAlbumId, title, + disambiguation, + duration, + overview, albumType, - statistics, + secondaryTypes, + statistics = {}, monitored, releaseDate, ratings, images, + genres, + links, media, + isSaving, isFetching, isPopulated, albumsError, + tracksError, trackFilesError, + hasTrackFiles, shortDateFormat, artist, previousAlbum, nextAlbum, isSearching, + onMonitorTogglePress, onSearchPress } = this.props; + const { + trackFileCount = 0, + sizeOnDisk = 0 + } = statistics; + const { isOrganizeModalOpen, + isRetagModalOpen, isArtistHistoryModalOpen, isInteractiveSearchModalOpen, isEditAlbumModalOpen, + isDeleteAlbumModalOpen, isManageTracksOpen, allExpanded, allCollapsed, expandedState } = this.state; + const fanartUrl = getFanartUrl(artist.images); + let expandIcon = icons.EXPAND_INDETERMINATE; + let trackFilesCountMessage = translate('TrackFilesCountMessage'); + + if (trackFileCount === 1) { + trackFilesCountMessage = '1 track file'; + } else if (trackFileCount > 1) { + trackFilesCountMessage = `${trackFileCount} track files`; + } if (allExpanded) { expandIcon = icons.COLLAPSE; @@ -179,14 +257,14 @@ class AlbumDetails extends Component { @@ -194,19 +272,28 @@ class AlbumDetails extends Component { + + @@ -214,28 +301,36 @@ class AlbumDetails extends Component { + + - +
@@ -244,38 +339,57 @@ class AlbumDetails extends Component {
-
-
- {title} +
+
+ +
+ +
+ +
+ +
-
+
@@ -283,10 +397,20 @@ class AlbumDetails extends Component {
+ { + duration ? + + {formatDuration(duration)} + : + null + } + + +
@@ -294,68 +418,134 @@ class AlbumDetails extends Component { + +
+ + + {formatBytes(sizeOnDisk)} + +
+ + } + tooltip={ + + {trackFilesCountMessage} + + } + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> + - - { - !!albumType && + albumType ? +
+ + + {albumType} + +
+ : + null } + { + secondaryTypes.length ? + : + null + } + + +
+ + + {translate('Links')} + +
+ + } + tooltip={ + + } + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> + +
+
+
@@ -363,24 +553,38 @@ class AlbumDetails extends Component {
{ - !isPopulated && !albumsError && !trackFilesError && - + !isPopulated && !albumsError && !tracksError && !trackFilesError ? + : + null } { - !isFetching && albumsError && -
Loading albums failed
+ !isFetching && albumsError ? + + {translate('AlbumsLoadError')} + : + null } { - !isFetching && trackFilesError && -
Loading track files failed
+ !isFetching && tracksError ? + + {translate('TracksLoadError')} + : + null + } + + { + !isFetching && trackFilesError ? + + {translate('TrackFilesLoadError')} + : + null } { isPopulated && !!media.length &&
- { media.slice(0).map((medium) => { return ( @@ -398,6 +602,14 @@ class AlbumDetails extends Component {
} + { + isPopulated && !media.length ? + + {translate('NoMediumInformation')} + : + null + } +
+ + - @@ -432,9 +652,17 @@ class AlbumDetails extends Component { albumId={id} artistId={artist.id} onModalClose={this.onEditAlbumModalClose} + onDeleteArtistPress={this.onDeleteAlbumPress} /> - + + + ); } @@ -444,28 +672,39 @@ AlbumDetails.propTypes = { id: PropTypes.number.isRequired, foreignAlbumId: PropTypes.string.isRequired, title: PropTypes.string.isRequired, + disambiguation: PropTypes.string, + duration: PropTypes.number, + overview: PropTypes.string, albumType: PropTypes.string.isRequired, + secondaryTypes: PropTypes.arrayOf(PropTypes.string).isRequired, statistics: PropTypes.object.isRequired, releaseDate: PropTypes.string.isRequired, ratings: PropTypes.object.isRequired, images: PropTypes.arrayOf(PropTypes.object).isRequired, + genres: PropTypes.arrayOf(PropTypes.string).isRequired, + links: PropTypes.arrayOf(PropTypes.object).isRequired, media: PropTypes.arrayOf(PropTypes.object).isRequired, monitored: PropTypes.bool.isRequired, shortDateFormat: PropTypes.string.isRequired, + isSaving: PropTypes.bool.isRequired, isSearching: PropTypes.bool, isFetching: PropTypes.bool, isPopulated: PropTypes.bool, albumsError: PropTypes.object, tracksError: PropTypes.object, trackFilesError: PropTypes.object, + hasTrackFiles: PropTypes.bool.isRequired, artist: PropTypes.object, previousAlbum: PropTypes.object, nextAlbum: PropTypes.object, + onMonitorTogglePress: PropTypes.func.isRequired, onRefreshPress: PropTypes.func, onSearchPress: PropTypes.func.isRequired }; AlbumDetails.defaultProps = { + secondaryTypes: [], + statistics: {}, isSaving: false }; diff --git a/frontend/src/Album/Details/AlbumDetailsConnector.js b/frontend/src/Album/Details/AlbumDetailsConnector.js index e10450745..7a5dbc95e 100644 --- a/frontend/src/Album/Details/AlbumDetailsConnector.js +++ b/frontend/src/Album/Details/AlbumDetailsConnector.js @@ -4,22 +4,44 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { findCommand } from 'Utilities/Command'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; -import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; -import { fetchTracks, clearTracks } from 'Store/Actions/trackActions'; -import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions'; -import { executeCommand } from 'Store/Actions/commandActions'; import * as commandNames from 'Commands/commandNames'; -import AlbumDetails from './AlbumDetails'; +import { toggleAlbumsMonitored } from 'Store/Actions/albumActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { clearTracks, fetchTracks } from 'Store/Actions/trackActions'; +import { clearTrackFiles, fetchTrackFiles } from 'Store/Actions/trackFileActions'; import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { findCommand, isCommandExecuting } from 'Utilities/Command'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import AlbumDetails from './AlbumDetails'; + +const selectTrackFiles = createSelector( + (state) => state.trackFiles, + (trackFiles) => { + const { + items, + isFetching, + isPopulated, + error + } = trackFiles; + + const hasTrackFiles = !!items.length; + + return { + isTrackFilesFetching: isFetching, + isTrackFilesPopulated: isPopulated, + trackFilesError: error, + hasTrackFiles + }; + } +); function createMapStateToProps() { return createSelector( (state, { foreignAlbumId }) => foreignAlbumId, (state) => state.tracks, - (state) => state.trackFiles, + selectTrackFiles, (state) => state.albums, createAllArtistSelector(), createCommandsSelector(), @@ -34,24 +56,43 @@ function createMapStateToProps() { return {}; } + const { + isTrackFilesFetching, + isTrackFilesPopulated, + trackFilesError, + hasTrackFiles + } = trackFiles; + const previousAlbum = sortedAlbums[albumIndex - 1] || _.last(sortedAlbums); const nextAlbum = sortedAlbums[albumIndex + 1] || _.first(sortedAlbums); - const isSearching = !!findCommand(commands, { name: commandNames.ALBUM_SEARCH }); + const isSearchingCommand = findCommand(commands, { name: commandNames.ALBUM_SEARCH }); + const isSearching = ( + isCommandExecuting(isSearchingCommand) && + isSearchingCommand.body.albumIds.indexOf(album.id) > -1 + ); + const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, artistId: artist.id })); + const isRenamingArtistCommand = findCommand(commands, { name: commandNames.RENAME_ARTIST }); + const isRenamingArtist = ( + isCommandExecuting(isRenamingArtistCommand) && + isRenamingArtistCommand.body.artistIds.indexOf(artist.id) > -1 + ); - const isFetching = tracks.isFetching || trackFiles.isFetching; - const isPopulated = tracks.isPopulated && trackFiles.isPopulated; + const isFetching = tracks.isFetching || isTrackFilesFetching; + const isPopulated = tracks.isPopulated && isTrackFilesPopulated; const tracksError = tracks.error; - const trackFilesError = trackFiles.error; return { ...album, shortDateFormat: uiSettings.shortDateFormat, artist, isSearching, + isRenamingFiles, + isRenamingArtist, isFetching, isPopulated, tracksError, trackFilesError, + hasTrackFiles, previousAlbum, nextAlbum }; @@ -64,9 +105,14 @@ const mapDispatchToProps = { fetchTracks, clearTracks, fetchTrackFiles, - clearTrackFiles + clearTrackFiles, + toggleAlbumsMonitored }; +function getMonitoredReleases(props) { + return _.map(_.filter(props.releases, { monitored: true }), 'id').sort(); +} + class AlbumDetailsConnector extends Component { componentDidMount() { @@ -76,10 +122,23 @@ class AlbumDetailsConnector extends Component { componentDidUpdate(prevProps) { const { - id + id, + anyReleaseOk, + isRenamingFiles, + isRenamingArtist } = this.props; - // If the id has changed we need to clear the tracks/track + if ( + (prevProps.isRenamingFiles && !isRenamingFiles) || + (prevProps.isRenamingArtist && !isRenamingArtist) || + !_.isEqual(getMonitoredReleases(prevProps), getMonitoredReleases(this.props)) || + (prevProps.anyReleaseOk === false && anyReleaseOk === true) + ) { + this.unpopulate(); + this.populate(); + } + + // If the id has changed we need to clear the album // files and fetch from the server. if (prevProps.id !== id) { @@ -101,22 +160,29 @@ class AlbumDetailsConnector extends Component { this.props.fetchTracks({ albumId }); this.props.fetchTrackFiles({ albumId }); - } + }; unpopulate = () => { this.props.clearTracks(); this.props.clearTrackFiles(); - } + }; // // Listeners + onMonitorTogglePress = (monitored) => { + this.props.toggleAlbumsMonitored({ + albumIds: [this.props.id], + monitored + }); + }; + onSearchPress = () => { this.props.executeCommand({ name: commandNames.ALBUM_SEARCH, albumIds: [this.props.id] }); - } + }; // // Render @@ -125,6 +191,7 @@ class AlbumDetailsConnector extends Component { return ( ); @@ -133,6 +200,9 @@ class AlbumDetailsConnector extends Component { AlbumDetailsConnector.propTypes = { id: PropTypes.number, + anyReleaseOk: PropTypes.bool, + isRenamingFiles: PropTypes.bool.isRequired, + isRenamingArtist: PropTypes.bool.isRequired, isAlbumFetching: PropTypes.bool, isAlbumPopulated: PropTypes.bool, foreignAlbumId: PropTypes.string.isRequired, @@ -140,6 +210,7 @@ AlbumDetailsConnector.propTypes = { clearTracks: PropTypes.func.isRequired, fetchTrackFiles: PropTypes.func.isRequired, clearTrackFiles: PropTypes.func.isRequired, + toggleAlbumsMonitored: PropTypes.func.isRequired, executeCommand: PropTypes.func.isRequired }; diff --git a/frontend/src/Album/Details/AlbumDetailsLinks.css b/frontend/src/Album/Details/AlbumDetailsLinks.css new file mode 100644 index 000000000..d37a082a1 --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetailsLinks.css @@ -0,0 +1,13 @@ +.links { + margin: 0; +} + +.link { + white-space: nowrap; +} + +.linkLabel { + composes: label from '~Components/Label.css'; + + cursor: pointer; +} diff --git a/frontend/src/Album/Details/AlbumDetailsLinks.css.d.ts b/frontend/src/Album/Details/AlbumDetailsLinks.css.d.ts new file mode 100644 index 000000000..9f91f93a4 --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetailsLinks.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'link': string; + 'linkLabel': string; + 'links': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Album/Details/AlbumDetailsLinks.js b/frontend/src/Album/Details/AlbumDetailsLinks.js new file mode 100644 index 000000000..241836bd4 --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetailsLinks.js @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import { kinds, sizes } from 'Helpers/Props'; +import styles from './AlbumDetailsLinks.css'; + +function AlbumDetailsLinks(props) { + const { + foreignAlbumId, + links + } = props; + + return ( +
+ + + + + + {links.map((link, index) => { + return ( + + + + + {(index > 0 && index % 5 === 0) && +
+ } + +
+ ); + })} + +
+ + ); +} + +AlbumDetailsLinks.propTypes = { + foreignAlbumId: PropTypes.string.isRequired, + links: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default AlbumDetailsLinks; diff --git a/frontend/src/Album/Details/AlbumDetailsMedium.css b/frontend/src/Album/Details/AlbumDetailsMedium.css index a89cca5f8..2bf1f6b1e 100644 --- a/frontend/src/Album/Details/AlbumDetailsMedium.css +++ b/frontend/src/Album/Details/AlbumDetailsMedium.css @@ -1,8 +1,8 @@ .medium { margin-bottom: 20px; - border: 1px solid $borderColor; + border: 1px solid var(--borderColor); border-radius: 4px; - background-color: $white; + background-color: var(--cardBackgroundColor); &:last-of-type { margin-bottom: 0; @@ -29,7 +29,7 @@ } .expandButton { - composes: link from 'Components/Link/Link.css'; + composes: link from '~Components/Link/Link.css'; flex-grow: 1; margin: 0 20px; @@ -48,13 +48,13 @@ } .actionsMenu { - composes: menu from 'Components/Menu/Menu.css'; + composes: menu from '~Components/Menu/Menu.css'; flex: 0 0 45px; } .actionsMenuContent { - composes: menuContent from 'Components/Menu/MenuContent.css'; + composes: menuContent from '~Components/Menu/MenuContent.css'; white-space: nowrap; font-size: 14px; @@ -65,23 +65,23 @@ } .actionButton { - composes: button from 'Components/Link/IconButton.css'; + composes: button from '~Components/Link/IconButton.css'; width: 30px; } .tracks { padding-top: 15px; - border-top: 1px solid $borderColor; + border-top: 1px solid var(--borderColor); } .collapseButtonContainer { padding: 10px 15px; width: 100%; - border-top: 1px solid $borderColor; + border-top: 1px solid var(--borderColor); border-bottom-right-radius: 4px; border-bottom-left-radius: 4px; - background-color: #fafafa; + background-color: var(--cardBackgroundColor); text-align: center; } diff --git a/frontend/src/Album/Details/AlbumDetailsMedium.css.d.ts b/frontend/src/Album/Details/AlbumDetailsMedium.css.d.ts new file mode 100644 index 000000000..94964280a --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetailsMedium.css.d.ts @@ -0,0 +1,21 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'actionButton': string; + 'actionMenuIcon': string; + 'actions': string; + 'actionsMenu': string; + 'actionsMenuContent': string; + 'collapseButtonContainer': string; + 'expandButton': string; + 'expandButtonIcon': string; + 'header': string; + 'left': string; + 'medium': string; + 'mediumFormat': string; + 'mediumNumber': string; + 'noTracks': string; + 'tracks': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Album/Details/AlbumDetailsMedium.js b/frontend/src/Album/Details/AlbumDetailsMedium.js index 2ba6bb975..9e80e2c7a 100644 --- a/frontend/src/Album/Details/AlbumDetailsMedium.js +++ b/frontend/src/Album/Details/AlbumDetailsMedium.js @@ -1,29 +1,24 @@ -import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import isAfter from 'Utilities/Date/isAfter'; -import isBefore from 'Utilities/Date/isBefore'; -import { icons, kinds, sizes } from 'Helpers/Props'; import Icon from 'Components/Icon'; -import IconButton from 'Components/Link/IconButton'; import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; import Link from 'Components/Link/Link'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; import TrackRowConnector from './TrackRowConnector'; import styles from './AlbumDetailsMedium.css'; function getMediumStatistics(tracks) { - let trackCount = 0; + const trackCount = tracks.length; let trackFileCount = 0; let totalTrackCount = 0; tracks.forEach((track) => { if (track.trackFileId) { - trackCount++; trackFileCount++; - } else if (track.monitored) { - trackCount++; } totalTrackCount++; @@ -69,16 +64,10 @@ class AlbumDetailsMedium extends Component { _expandByDefault() { const { mediumNumber, - onExpandPress, - items + onExpandPress } = this.props; - const expand = _.some(items, (item) => { - return isAfter(item.airDateUtc) || - isAfter(item.airDateUtc, { days: -30 }); - }); - - onExpandPress(mediumNumber, expand && mediumNumber > 0); + onExpandPress(mediumNumber, mediumNumber === 1); } // @@ -91,7 +80,7 @@ class AlbumDetailsMedium extends Component { } = this.props; this.props.onExpandPress(mediumNumber, !isExpanded); - } + }; // // Render @@ -129,7 +118,7 @@ class AlbumDetailsMedium extends Component { }