Merge branch 'release/1.0.0'
Some checks reported errors
Gitea Test/public-plex-watchlist/pipeline/head This commit looks good
plex-watchlist/pipeline/head Something is wrong with the build of this commit
Plex Watchlist Analysis/pipeline/head This commit looks good
MAW.me/pipeline/head Build queued...

This commit is contained in:
2024-07-15 01:35:53 -04:00
19 changed files with 7698 additions and 2 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"
}

9
CHANGELOG.md Normal file
View File

@@ -0,0 +1,9 @@
# Changelog
## 1.0.0
1. Initial release
2. Load from RSS with automatic handling of paging in background load
3. Searching and filtering, with faceted filters on keywords and rating
Doesn't include the keyword chips being clickable to add to the column filter.

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+\\.\\d+\\.\\d+', 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()
}
}
}
}

View File

@@ -1,2 +1,5 @@
# public-plex-watchlist
Turn your Plex Watchlist RSS feed into an interface non-Plex folks can search and filter
# Public Plex Watchlist
Turn your Plex Watchlist RSS feed into an interface non-Plex folks can search and filter.
[Changelog](/CHANGELOG.md)

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": "1.0.0",
"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",
"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",
"prop-types": "^15.8.1",
"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,59 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { fetchPlexWatchlistFeed } from '../../api/plexApi';
import { useCallback, useEffect, useMemo } from 'react';
import React from 'react';
import WatchlistTable from '../WatchlistTable';
export default function Watchlist() {
const {
data,
isPending,
isLoading,
isLoadingError,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['watchlistItems'],
queryFn: fetchPlexWatchlistFeed,
initialPageParam: process.env.BASE_RSS_FEED,
getNextPageParam: (lastPage) => lastPage?.paginationLinks?.next,
refetchOnWindowFocus: false,
});
let loadMoreItems = useCallback(
(containerRefElement) => {
if (!isFetching && hasNextPage) {
fetchNextPage();
}
// }
},
[fetchNextPage, isFetching, hasNextPage]
);
useEffect(() => {
if (hasNextPage && !isFetchingNextPage) {
loadMoreItems();
}
}, [loadMoreItems, hasNextPage, isFetchingNextPage, data]);
const flatItems = useMemo(
() => data?.pages?.flatMap((row) => row.items) ?? [],
[data]
);
return (
<>
{!isPending && (
<WatchlistTable
items={flatItems}
isLoadingItems={isLoading}
isErrorLoading={isLoadingError}
isPageLoading={isFetchingNextPage}
loadMoreItems={loadMoreItems}
/>
)}
</>
);
}

View File

@@ -0,0 +1,149 @@
import { useMemo, useRef, useEffect, useState, useCallback } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import { Chip, Link, Stack } from '@mui/material';
import LaunchOutlined from '@mui/icons-material/Launch';
import PropTypes from 'prop-types';
export default function WatchlistTable({
items,
isLoadingItems,
isErrorLoading,
isPageLoading,
}) {
const rowVirtualizerInstanceRef = useRef(null);
const [columnFilters, setColumnFilters] = useState([]);
const [globalFilter, setGlobalFilter] = useState();
const [sorting, setSorting] = useState([]);
const mediaKeywords = 'media:keywords';
//scroll to top of table when sorting or filters change
useEffect(() => {
try {
if (rowVirtualizerInstanceRef.current?.getTotalSize() > 0) {
rowVirtualizerInstanceRef.current?.scrollToIndex(0);
}
} catch (error) {
console.error(error);
}
}, [sorting, columnFilters, globalFilter]);
const keywordOptions = useMemo(
() =>
[...new Set(items.flatMap((item) => item[mediaKeywords].split(', ')))]
.sort()
.map((keyword) => {
return { label: keyword, value: keyword };
}),
[items]
);
const getKeywordsForRow = useCallback((renderedCellValue, row) => {
if (typeof renderedCellValue === 'string') {
return renderedCellValue.split(', ');
}
return row.original[mediaKeywords].split(', ');
}, []);
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[mediaKeywords].split(',');
},
Cell: ({ renderedCellValue, row }) => (
<Stack direction="row" spacing={0.25} useFlexGap flexWrap="wrap">
{getKeywordsForRow(renderedCellValue, row).map((keyword) => (
<Chip label={keyword} key={keyword} />
))}
</Stack>
),
minSize: 200,
size: 400,
maxSize: 700,
filterSelectOptions: keywordOptions,
filterVariant: 'multi-select',
filterFn: (row, columnId, filterValue) => {
return (
row.original[mediaKeywords]
.split(',')
.filter((keyword) => keyword.includes(filterValue)).length > 0
);
},
},
{
header: 'Rating',
id: 'mediaRating',
accessorFn: (row) => row['media:rating']?.toUpperCase(),
filterVariant: 'multi-select',
},
],
[keywordOptions, getKeywordsForRow]
);
const table = useMaterialReactTable({
columns,
data: items,
layoutMode: 'semantic',
enableFacetedValues: true,
enablePagination: false,
enableRowVirtualization: true,
rowVirtualizerInstanceRef,
rowVirtualizerOptions: { overscan: 4 },
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onSortingChange: setSorting,
state: {
isLoading: isLoadingItems,
showProgressBars: isPageLoading,
showAlertBanner: isErrorLoading,
columnFilters,
globalFilter,
sorting,
},
initialState: {
showGlobalFilter: true,
},
});
return <MaterialReactTable table={table} />;
}
WatchlistTable.propTypes = {
items: PropTypes.array.isRequired,
isLoadingItems: PropTypes.bool.isRequired,
isErrorLoading: PropTypes.bool.isRequired,
isPageLoading: PropTypes.bool.isRequired,
loadMoreItems: PropTypes.func.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>
);