Merge pull request #1 from averymd/feature/initial

WAT-2, WAT-4: Initial creation
This commit is contained in:
Melissa Avery-Weir
2024-07-11 03:25:18 -04:00
committed by GitHub
17 changed files with 7629 additions and 0 deletions

1
.env.production Normal file
View File

@@ -0,0 +1 @@
BASE_RSS_FEED=https://rss.plex.tv/d949de26-087e-4c7b-94d3-0f36b982ba21

2
.gitignore vendored
View File

@@ -128,3 +128,5 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.vscode

14
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,14 @@
# .pre-commit-config.yaml
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
exclude: package.lock.json
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0 # Use the ref you want to point at
hooks:
- id: trailing-whitespace
- id: check-json
#- id: check-yaml

127
.secrets.baseline Normal file
View File

@@ -0,0 +1,127 @@
{
"version": "1.5.0",
"plugins_used": [
{
"name": "ArtifactoryDetector"
},
{
"name": "AWSKeyDetector"
},
{
"name": "AzureStorageKeyDetector"
},
{
"name": "Base64HighEntropyString",
"limit": 4.5
},
{
"name": "BasicAuthDetector"
},
{
"name": "CloudantDetector"
},
{
"name": "DiscordBotTokenDetector"
},
{
"name": "GitHubTokenDetector"
},
{
"name": "GitLabTokenDetector"
},
{
"name": "HexHighEntropyString",
"limit": 3.0
},
{
"name": "IbmCloudIamDetector"
},
{
"name": "IbmCosHmacDetector"
},
{
"name": "IPPublicDetector"
},
{
"name": "JwtTokenDetector"
},
{
"name": "KeywordDetector",
"keyword_exclude": ""
},
{
"name": "MailchimpDetector"
},
{
"name": "NpmDetector"
},
{
"name": "OpenAIDetector"
},
{
"name": "PrivateKeyDetector"
},
{
"name": "PypiTokenDetector"
},
{
"name": "SendGridDetector"
},
{
"name": "SlackDetector"
},
{
"name": "SoftlayerDetector"
},
{
"name": "SquareOAuthDetector"
},
{
"name": "StripeDetector"
},
{
"name": "TelegramBotTokenDetector"
},
{
"name": "TwilioKeyDetector"
}
],
"filters_used": [
{
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
},
{
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
"min_level": 2
},
{
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
},
{
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
},
{
"path": "detect_secrets.filters.heuristic.is_lock_file"
},
{
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
},
{
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
},
{
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
},
{
"path": "detect_secrets.filters.heuristic.is_sequential_string"
},
{
"path": "detect_secrets.filters.heuristic.is_swagger_file"
},
{
"path": "detect_secrets.filters.heuristic.is_templated_secret"
}
],
"results": {},
"generated_at": "2024-06-28T03:54:46Z"
}

94
CodeAnalysisJenkinsfile Normal file
View File

@@ -0,0 +1,94 @@
pipeline {
agent {
label 'python311 && amd64'
}
options {
quietPeriod(120)
disableConcurrentBuilds()
}
tools {nodejs "Node 20"}
environment {
DEPCHECK_SCAN_ACCOUNT = credentials('DEPCHECK_SCAN_ACCOUNT')
DEPCHECK_CONNSTRING = credentials('DEPCHECK_CONNSTRING')
SONATYPE_OSSINDEX_API_KEY = credentials('SONATYPE_OSSINDEX_API_KEY')
SONAR_SCANNER_OPTS = '-Xmx768m'
}
stages {
stage('Install Python Virtual Enviroment') {
steps {
sh 'echo $PATH'
sh 'python3.11 -m venv env'
}
}
stage('Install Application Dependencies') {
steps {
sh '''
. env/bin/activate
pip3.11 install --upgrade pip
pip3.11 install -r requirements.txt
corepack enable
npm install
mkdir reports
deactivate
'''
}
}
stage('ESLint') {
steps {
script {
try {
sh ". env/bin/activate && npx eslint . -c eslint.config.mjs -o reports/eslint.json --format json || true"
sh ". env/bin/activate && npx eslint . -c eslint.config.mjs -o reports/eslint-checkstyle.report --format checkstyle || true"
}
finally {
recordIssues tool: esLint(pattern: '**/reports/eslint-checkstyle.report'), aggregatingResults: true
}
}
}
}
stage('OWASP Dependency-Check Vulnerabilities') {
steps {
sh 'curl -O https://jdbc.postgresql.org/download/postgresql-42.7.3.jar'
dependencyCheck odcInstallation: 'DepCheck',
additionalArguments: '--project "Plex Watchlist" -o ./reports -f XML -f HTML -f JSON -f CSV --noupdate --connectionString $DEPCHECK_CONNSTRING --dbDriverPath postgresql-42.7.3.jar --dbDriverName org.postgresql.Driver --dbUser $DEPCHECK_SCAN_ACCOUNT_USR --dbPassword $DEPCHECK_SCAN_ACCOUNT_PSW --ossIndexUsername averymd@irrsinn.net --ossIndexPassword $SONATYPE_OSSINDEX_API_KEY'
dependencyCheckPublisher pattern: 'reports/dependency-check-report.xml'
}
}
stage('SonarQube Analysis') {
environment {
scannerHome = tool 'SonarQubeDefault'
}
steps {
withSonarQubeEnv('Personal SonarQube') {
sh """
. env/bin/activate
${scannerHome}/bin/sonar-scanner \
-Dsonar.dependencyCheck.jsonReportPath=reports/dependency-check-report.json \
-Dsonar.dependencyCheck.xmlReportPath=reports/dependency-check-report.xml \
-Dsonar.dependencyCheck.htmlReportPath=reports/dependency-check-report.html \
-Dsonar.eslint.reportPaths=reports/eslint.json
deactivate
"""
}
}
}
}
post {
cleanup {
cleanWs()
dir("${env.WORKSPACE}@tmp") {
deleteDir()
}
dir("${env.WORKSPACE}@2") {
deleteDir()
}
dir("${env.WORKSPACE}@2@tmp") {
deleteDir()
}
}
}
}

74
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,74 @@
pipeline {
agent {
label 'python311 && amd64'
}
options {
disableConcurrentBuilds()
}
tools {nodejs "Node 20"}
stages {
stage('Install Python Virtual Enviroment') {
steps {
sh 'python3.11 -m venv env'
}
}
stage('Install Application Dependencies') {
steps {
sh '''
. env/bin/activate
pip3.11 install --upgrade pip
pip3.11 install -r requirements.txt
corepack enable
npm install
deactivate
'''
}
}
stage('Build') {
steps {
sh 'npm run build'
}
}
stage('Deploy if tagged') {
when {
tag pattern: '(\\d+\\.){3}', comparator: "REGEXP"
}
steps {
s3Upload profileName: 'Irrsinn.net Buckets',
userMetadata: [],
dontWaitForConcurrentBuildCompletion: false,
dontSetBuildResultOnFailure: false,
pluginFailureResultConstraint: 'FAILURE',
consoleLogLevel: 'INFO',
entries: [[
bucket: 'plex-watchlist',
sourceFile:'dist/**',
managedArtifacts: false,
keepForever: true,
noUploadOnFailure: true,
selectedRegion: 'us-east-1'
]]
}
}
}
post {
cleanup {
cleanWs()
dir("${env.WORKSPACE}@tmp") {
deleteDir()
}
dir("${env.WORKSPACE}@2") {
deleteDir()
}
dir("${env.WORKSPACE}@2@tmp") {
deleteDir()
}
}
}
}

45
eslint.config.mjs Normal file
View File

@@ -0,0 +1,45 @@
import { fixupConfigRules } from '@eslint/compat';
import globals from 'globals';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import js from '@eslint/js';
import { FlatCompat } from '@eslint/eslintrc';
import sonarjs from 'eslint-plugin-sonarjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
allConfig: js.configs.all,
});
export default [
{
ignores: ['**/libs/*.js', 'node_modules/**/*', '.env/**/*', 'dist/**/*'],
},
sonarjs.configs.recommended,
...fixupConfigRules(
compat.extends(
'plugin:react-hooks/recommended',
'plugin:@tanstack/eslint-plugin-query/recommended'
)
),
{
languageOptions: {
globals: {
...globals.browser,
},
parserOptions: {
ecmaFeatures: {
jsx: true,
modules: true,
},
},
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {},
},
];

6998
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

62
package.json Normal file
View File

@@ -0,0 +1,62 @@
{
"name": "public-plex-watchlist",
"version": "0.0.1",
"description": "Turn your Plex Watchlist RSS feed into an interface non-Plex folks can search and filter.",
"source": "src/index.html",
"scripts": {
"start": "parcel",
"build": "parcel build",
"lint": "npx eslint . -c eslint.config.mjs",
"lint-watch": "nodemon --exec npm run lint",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/averymd/public-plex-watchlist.git"
},
"keywords": [
"plex",
"reactjs"
],
"author": "Melissa Avery-Weir",
"license": "Unlicense",
"bugs": {
"url": "https://github.com/averymd/public-plex-watchlist/issues"
},
"homepage": "https://github.com/averymd/public-plex-watchlist#readme",
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@mui/icons-material": "^5.15.20",
"@mui/material": "^5.15.20",
"@mui/x-date-pickers": "^7.7.1",
"@tanstack/react-query": "^5.45.1",
"axios": "^1.7.2",
"globals": "^15.6.0",
"material-react-table": "^2.13.0",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"rss-parser": "^3.13.0"
},
"devDependencies": {
"@eslint/compat": "^1.1.0",
"@tanstack/eslint-plugin-query": "^5.43.1",
"buffer": "^6.0.3",
"eslint": "^9.5.0",
"eslint-formatter-checkstyle": "^8.40.0",
"eslint-plugin-react-hooks": "^5.1.0-rc-1434af3d22-20240618",
"eslint-plugin-sonarjs": "^1.0.3",
"events": "^3.3.0",
"https-browserify": "^1.0.0",
"nodemon": "^3.1.4",
"parcel": "^2.12.0",
"process": "^0.11.10",
"punycode": "^1.4.1",
"stream-browserify": "^3.0.0",
"stream-http": "^3.2.0",
"string_decoder": "^1.3.0",
"timers-browserify": "^2.0.12",
"url": "^0.11.3"
}
}

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
detect-secrets==1.5.0
pre-commit==3.7.1

3
sonar-project.properties Normal file
View File

@@ -0,0 +1,3 @@
sonar.projectKey=averymd_public-plex-watchlist_3d43ef27-fec8-4c37-9758-6f95ec5635cc
sonar.exclusions=env/**,node_modules/**
sonar.python.version=3.11

12
src/App.js Normal file
View File

@@ -0,0 +1,12 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import Watchlist from './components/Watchlist';
export default function App() {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<Watchlist />
</QueryClientProvider>
);
}

15
src/api/plexApi.js Normal file
View File

@@ -0,0 +1,15 @@
import Parser from 'rss-parser';
export async function fetchPlexWatchlistFeed({ pageParam }) {
let rssParser = new Parser({
customFields: {
item: [
'media:credit',
'media:thumbnail',
'media:keywords',
'media:rating',
],
},
});
return rssParser.parseURL(pageParam);
}

View File

@@ -0,0 +1,46 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { fetchPlexWatchlistFeed } from '../../api/plexApi';
import { useEffect, useMemo } from 'react';
import React from 'react';
import WatchlistTable from '../WatchlistTable';
export default function Watchlist() {
const {
data,
isPending,
isLoadingError,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['watchlistItems'],
queryFn: fetchPlexWatchlistFeed,
initialPageParam: process.env.BASE_RSS_FEED,
getNextPageParam: (lastPage) => lastPage?.paginationLinks?.next,
});
useEffect(() => {
if (!isFetching && !isFetchingNextPage && hasNextPage) {
fetchNextPage();
}
}, [isFetching, isFetchingNextPage, hasNextPage, fetchNextPage]);
const justItems = useMemo(
() => data?.pages?.flatMap((row) => row.items) ?? [],
[data]
);
return (
<>
{!isPending && (
<WatchlistTable
items={justItems}
isLoadingItems={isFetching}
isErrorLoading={isLoadingError}
isPageLoading={isFetchingNextPage}
/>
)}
</>
);
}

View File

@@ -0,0 +1,107 @@
import { useMemo, useState } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import { Chip, Link, Stack } from '@mui/material';
import LaunchOutlined from '@mui/icons-material/Launch';
export default function WatchlistTable({
items,
isLoadingItems,
isErrorLoading,
isPageLoading,
}) {
const keywordOptions = useMemo(
() =>
[
...new Set(items.flatMap((item) => item['media:keywords'].split(', '))),
].map((keyword) => {
return { label: keyword, value: keyword };
}),
[items]
);
const columns = useMemo(
() => [
{
header: 'Title',
accessorKey: 'title',
Cell: ({ renderedCellValue, row }) => (
<Link href={row.original.link} target="_blank">
{renderedCellValue} <LaunchOutlined fontSize="x-small" />
</Link>
),
size: 250,
},
{
header: 'Type',
id: 'category',
accessorFn: (row) => row.categories[0],
filterVariant: 'select',
size: 125,
},
{
header: 'Summary',
accessorKey: 'content',
minSize: 200,
size: 400,
maxSize: 600,
grow: false,
},
{
header: 'Keywords',
id: 'keywords',
accessorFn: (row) => {
return row['media:keywords'].split(', ');
},
Cell: ({ renderedCellValue, row }) => (
<Stack direction="row" spacing={0.25} useFlexGap flexWrap="wrap">
{renderedCellValue.map((keyword) => (
<Chip label={keyword} key={keyword} />
))}
</Stack>
),
minSize: 200,
size: 400,
maxSize: 700,
filterSelectOptions: keywordOptions,
},
{
header: 'Rating',
id: 'mediaRating',
accessorFn: (row) => row['media:rating']?.toUpperCase(),
filterVariant: 'multi-select',
},
],
[keywordOptions]
);
const table = useMaterialReactTable({
columns,
data: items,
layoutMode: 'semantic',
filterFromLeafRows: true,
enableFacetedValues: true,
enablePagination: false,
enableRowVirtualization: true,
state: {
isLoading: isLoadingItems,
showAlertBanner: isErrorLoading,
showProgressBars: isPageLoading,
},
initialState: {
showColumnFilters: true,
showGlobalFilter: true,
},
});
return <MaterialReactTable table={table} />;
}
WatchlistTable.propTypes = {
items: PropTypes.object.isRequired,
isLoadingItems: PropTypes.bool.isRequired,
isErrorLoading: PropTypes.bool.isRequired,
isPageLoading: PropTypes.bool.isRequired,
};

15
src/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Plex Watchlist</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="index.js"></script>
</body>
</html>

12
src/index.js Normal file
View File

@@ -0,0 +1,12 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<App />
</StrictMode>
);