├── .dockerignore ├── .eslintrc.cjs ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── docker-image.yml │ ├── docker-latest.yml │ └── stale.yml ├── .gitignore ├── .vscode └── launch.json ├── Dockerfile ├── LICENSE ├── README.md ├── backend ├── .env.example ├── classes │ ├── api-loader.js │ ├── axios.js │ ├── backup.js │ ├── config.js │ ├── db-helper.js │ ├── emby-api.js │ ├── env.js │ ├── jellyfin-api.js │ ├── logging.js │ ├── task-manager-singleton.js │ ├── task-manager.js │ ├── task-scheduler-singleton.js │ └── task-scheduler.js ├── create_database.js ├── db.js ├── global │ ├── backup_tables.js │ └── task-list.js ├── logging │ ├── taskName.js │ ├── taskstate.js │ └── triggertype.js ├── migrations.js ├── migrations │ ├── 001_app_config_table.js │ ├── 002_jf_activity_watchdog_table.js │ ├── 003_jf_libraries_table.js │ ├── 004_jf_library_items_table.js │ ├── 005_jf_library_seasons_table.js │ ├── 006_jf_library_episodes_table.js │ ├── 007_jf_playback_activity_table.js │ ├── 008_js_users_table.js │ ├── 009_jf_all_user_activity_view.js │ ├── 010_jf_library_count_view.js │ ├── 011_js_library_stats_overview_view.js │ ├── 012_fs_last_library_activity_function.js │ ├── 013_fs_last_user_activity_function.js │ ├── 014_fs_library_stats_function.js │ ├── 015_fs_most_active_user_function.js │ ├── 016_fs_most_played_items_function.js │ ├── 017_fs_most_popular_items_function.js │ ├── 018_fs_most_used_clients_function.js │ ├── 019_fs_most_viewed_libraries_function.js │ ├── 020_fs_user_stats_function.js │ ├── 021_fs_watch_stats_over_time_functions.js │ ├── 022_fs_watch_stats_popular_days_of_week_function.js │ ├── 023_fs_watch_stats_popular_hour_of_day_function,js.js │ ├── 024_jf_item_info_table.js │ ├── 025_fs_last_library_activity_function.js │ ├── 026_fs_last_user_activity_function.js │ ├── 027_js_library_metadata_view.js │ ├── 028_jf_playback_reporting_plugin_data_table.js │ ├── 029_jf_all_user_activity_view.js │ ├── 030_jf_logging_table.js │ ├── 031_jd_remove_orphaned_data.js │ ├── 032_app_config_table_add_auth_flag.js │ ├── 033_js_library_stats_overview_view.js │ ├── 034_jf_libraries_table_add_stat_columns.js │ ├── 035_ju_update_library_stats_data.js │ ├── 036_js_library_stats_overview_view.js │ ├── 037_jf_library_items_with_playcount_playtime.js │ ├── 038_jf_playback_activity_add_stream_data_columns.js │ ├── 039_jf_activity_watchdog_add_stream_data_columns.js │ ├── 040_app_config_add_general_settings_column.js │ ├── 041_js_library_stats_overview_view_optimizations.js │ ├── 042_app_config_add_api_keys_column.js │ ├── 043_jf_playback_activity_add_serverid_column.js │ ├── 044_jf_activity_watchdog_add_serverid_column.js │ ├── 045_ji_insert_playback_plugin_data_to_activity_table.js │ ├── 046_jf_library_items_table_add_archived_column.js │ ├── 047_jf_library_items_with_playcount_playtime.js │ ├── 048_fs_last_user_activity.js │ ├── 049_fs_last_library_activity.js │ ├── 050_fs_most_played_items.js │ ├── 051_fs_most_popular_items.js │ ├── 052_jf_libraries_table_add_archived_column.js │ ├── 053_js_library_stats_overview.js │ ├── 054_jf_library_episodes_table_add_archived_column.js │ ├── 055_jf_library_seasons_table_add_archived_column.js │ ├── 056_js_library_metadata.js │ ├── 057_jf_library_count_view.js │ ├── 058_jf_recent_playback_activity_function.js │ ├── 059_jf_recent_playback_activity_function_fix.js │ ├── 060_jf_recent_playback_activity_function_fix_2.js │ ├── 061_jf_recent_playback_activity_function_fix_3.js │ ├── 062_jf_library_items_with_playcount_playtime_added_size.js │ ├── 063_fs_last_user_activity_fix_1.js │ ├── 064_app_config_remove_default_identity.js │ ├── 065_ji_insert_playback_plugin_data_to_activity_table_fixes.js │ ├── 066_jf_item_info_add_date_created.js │ ├── 067_jf_library_items_with_playcount_playtime_added_date_created.js │ ├── 068_ji_insert_playback_plugin_data_to_activity_table_fixes_2.js │ ├── 069_jf_recent_playback_activity_function_fix_4.js │ ├── 070_jf_playback_activity_add_unique_constraint.js │ ├── 071_jf_watchdog_table_add_activity_id_field.js │ ├── 072_jf_library_episodes_add_primaty_image_hash.js │ ├── 073_fs_watch_stats_over_time_exclude_archived_libraries.js │ ├── 074_fs_watch_stats_popular_days_of_week_exclude_archived_libraries.js │ ├── 075_fs_watch_stats_popular_hour_of_day_exclude_archived_libraries.js │ ├── 076_fs_user_stats_fixed_hours_range.js │ ├── 077_jf_recent_playback_activity_fix_devide_by_zero.js │ ├── 078_refactor_fs_user_stats.js │ ├── 079_create_view_jf_playback_activity_with_metadata.js │ ├── 080_js_latest_playback_activity.js │ ├── 081_create_trigger_refresh_function_js_latest_playback_activity.js │ ├── 082_create_trigger_refresh_js_latest_playback_activity.js │ ├── 083_create_materialized_view_js_library_stats_overview.js │ ├── 084_create_trigger_refresh_function_js_library_stats_overview.js │ ├── 085_create_trigger_refresh_js_library_stats_overview.js │ ├── 086_drop_all_refresh_triggers_on_activity_table.js │ ├── 087_optimize_js_latest_playback_activity.js │ ├── 088_optimize_materialized_view_js_library_stats_overview.js │ ├── 089_optimize_materialized_view_js_library_stats_overview_last_activity.js │ ├── 090_create_function_fs_get_user_activity.js │ ├── 091_fix_function_fs_get_user_activity.js │ ├── 092_fix_function_fs_get_user_activity_interrupt_groups_properly.js │ ├── 093_ji_insert_playback_plugin_data_to_activity_table_EpisodeID_Fix.js │ └── 094_jf_library_items_table_add_genres.js ├── models │ ├── bulk_insert_update_handler.js │ ├── jf_activity_watchdog.js │ ├── jf_item_info.js │ ├── jf_libraries.js │ ├── jf_library_episodes.js │ ├── jf_library_items.js │ ├── jf_library_seasons.js │ ├── jf_logging.js │ ├── jf_playback_activity.js │ ├── jf_playback_reporting_plugin_data.js │ └── jf_users.js ├── nodemon.json ├── routes │ ├── api.js │ ├── auth.js │ ├── backup.js │ ├── logging.js │ ├── proxy.js │ ├── stats.js │ ├── sync.js │ └── utils.js ├── server.js ├── socket-io-client.js ├── swagautogen.js ├── swagger.json ├── tasks │ ├── ActivityMonitor.js │ ├── BackupTask.js │ ├── FullSyncTask.js │ ├── PlaybackReportingPluginSyncTask.js │ └── RecentlyAddedItemsSyncTask.js ├── utils │ └── sanitizer.js ├── version-control.js ├── ws-server-singleton.js └── ws.js ├── build.sh ├── docker-compose.yml ├── entry.sh ├── index.html ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── icon-b-192.png ├── icon-b-512.png ├── locales │ ├── ca-ES │ │ └── translation.json │ ├── en-UK │ │ └── translation.json │ ├── fr-FR │ │ └── translation.json │ ├── it-IT │ │ └── translation.json │ └── zh-CN │ │ └── translation.json ├── manifest.json └── robots.txt ├── release.sh ├── screenshots ├── Activity.PNG ├── Home.PNG ├── Libraries.PNG ├── Users.PNG ├── settings.PNG └── stats.PNG ├── src ├── App.css ├── App.jsx ├── App.test.jsx ├── assets │ └── react.svg ├── index.css ├── index.jsx ├── lib │ ├── axios_instance.jsx │ ├── baseurl.jsx │ ├── config.jsx │ ├── devices.jsx │ ├── languages.jsx │ ├── navdata.jsx │ └── tasklist.jsx ├── pages │ ├── about.jsx │ ├── activity.jsx │ ├── activity_time_line.jsx │ ├── components │ │ ├── HomeStatisticCards.jsx │ │ ├── LibrarySelector │ │ │ └── SelectionCard.jsx │ │ ├── activity-timeline │ │ │ ├── activity-timeline-item.jsx │ │ │ ├── activity-timeline.jsx │ │ │ └── helpers.jsx │ │ ├── activity │ │ │ ├── activity-table.jsx │ │ │ └── stream_info.jsx │ │ ├── general │ │ │ ├── ComponentLoading.jsx │ │ │ ├── ErrorBoundary.jsx │ │ │ ├── busyLoader.jsx │ │ │ ├── error.jsx │ │ │ ├── globalStats.jsx │ │ │ ├── globalstats │ │ │ │ └── watchtimestats.jsx │ │ │ ├── last-watched-card.jsx │ │ │ ├── loading.jsx │ │ │ ├── navbar.jsx │ │ │ └── version-card.jsx │ │ ├── ip-info.jsx │ │ ├── item-info.jsx │ │ ├── item-info │ │ │ ├── item-activity.jsx │ │ │ ├── item-not-found.jsx │ │ │ ├── item-options.jsx │ │ │ ├── more-items.jsx │ │ │ └── more-items │ │ │ │ └── more-items-card.jsx │ │ ├── library-info.jsx │ │ ├── library │ │ │ ├── RecentlyAdded │ │ │ │ └── recently-added-card.jsx │ │ │ ├── genre-library-stats.jsx │ │ │ ├── last-watched.jsx │ │ │ ├── library-activity.jsx │ │ │ ├── library-card.jsx │ │ │ ├── library-filter-modal.jsx │ │ │ ├── library-items.jsx │ │ │ ├── library-options.jsx │ │ │ └── recently-added.jsx │ │ ├── libraryOverview.jsx │ │ ├── libraryStatCard │ │ │ └── library-stat-component.jsx │ │ ├── playbackactivity.jsx │ │ ├── sessions │ │ │ ├── session-card.jsx │ │ │ └── sessions.jsx │ │ ├── settings │ │ │ ├── Task.jsx │ │ │ ├── Tasks.jsx │ │ │ ├── TerminalComponent.jsx │ │ │ ├── apiKeys.jsx │ │ │ ├── backup_page.jsx │ │ │ ├── backup_tables.jsx │ │ │ ├── backupfiles.jsx │ │ │ ├── logs.jsx │ │ │ ├── security.jsx │ │ │ └── settingsConfig.jsx │ │ ├── statCards │ │ │ ├── ItemStatComponent.jsx │ │ │ ├── genre-stat-card.jsx │ │ │ ├── most_active_users.jsx │ │ │ ├── most_used_client.jsx │ │ │ ├── mp_movies.jsx │ │ │ ├── mp_music.jsx │ │ │ ├── mp_series.jsx │ │ │ ├── mv_libraries.jsx │ │ │ ├── mv_movies.jsx │ │ │ ├── mv_music.jsx │ │ │ ├── mv_series.jsx │ │ │ └── playback_method_stats.jsx │ │ ├── statistics │ │ │ ├── chart.jsx │ │ │ ├── daily-play-count.jsx │ │ │ ├── play-method-chart.jsx │ │ │ ├── play-stats-by-day.jsx │ │ │ ├── play-stats-by-hour.jsx │ │ │ └── playbackMethodStats.jsx │ │ ├── user-info.jsx │ │ └── user-info │ │ │ ├── genre-user-stats.jsx │ │ │ ├── lastplayed.jsx │ │ │ └── user-activity.jsx │ ├── css │ │ ├── about.css │ │ ├── activity.css │ │ ├── activity │ │ │ ├── activity-table.css │ │ │ └── stream-info.css │ │ ├── error.css │ │ ├── genres.css │ │ ├── globalstats.css │ │ ├── home.css │ │ ├── items │ │ │ ├── item-details.css │ │ │ └── item-stat-component.css │ │ ├── lastplayed.css │ │ ├── library │ │ │ ├── libraries.css │ │ │ ├── library-card.css │ │ │ └── media-items.css │ │ ├── libraryOverview.css │ │ ├── loading.css │ │ ├── navbar.css │ │ ├── radius_breakpoint_css.css │ │ ├── recent.css │ │ ├── sessions.css │ │ ├── settings │ │ │ ├── backups.css │ │ │ ├── settings.css │ │ │ └── version.css │ │ ├── setup.css │ │ ├── statCard.css │ │ ├── stats.css │ │ ├── timeline │ │ │ └── activity-timeline.css │ │ ├── users │ │ │ ├── user-activity.css │ │ │ ├── user-details.css │ │ │ └── users.css │ │ ├── variables.module.css │ │ ├── websocket │ │ │ └── websocket.css │ │ └── width_breakpoint_css.css │ ├── debugTools │ │ ├── session-card.jsx │ │ ├── sessionCard.css │ │ └── sessions.jsx │ ├── fonts │ │ ├── Raleway-Italic-VariableFont_wght.ttf │ │ ├── Raleway-VariableFont_wght.ttf │ │ └── static │ │ │ ├── Raleway-Black.ttf │ │ │ ├── Raleway-BlackItalic.ttf │ │ │ ├── Raleway-Bold.ttf │ │ │ ├── Raleway-BoldItalic.ttf │ │ │ ├── Raleway-ExtraBold.ttf │ │ │ ├── Raleway-ExtraBoldItalic.ttf │ │ │ ├── Raleway-ExtraLight.ttf │ │ │ ├── Raleway-ExtraLightItalic.ttf │ │ │ ├── Raleway-Italic.ttf │ │ │ ├── Raleway-Light.ttf │ │ │ ├── Raleway-LightItalic.ttf │ │ │ ├── Raleway-Medium.ttf │ │ │ ├── Raleway-MediumItalic.ttf │ │ │ ├── Raleway-Regular.ttf │ │ │ ├── Raleway-SemiBold.ttf │ │ │ ├── Raleway-SemiBoldItalic.ttf │ │ │ ├── Raleway-Thin.ttf │ │ │ └── Raleway-ThinItalic.ttf │ ├── home.jsx │ ├── images │ │ ├── icon-b-512.png │ │ ├── icon-b-512.svg │ │ ├── icon-w-512.png │ │ └── icon-w-512.svg │ ├── libraries.jsx │ ├── library_selector.jsx │ ├── login.jsx │ ├── settings.jsx │ ├── setup.jsx │ ├── signup.jsx │ ├── statistics.jsx │ ├── testing.jsx │ └── users.jsx ├── routes.jsx ├── setupTests.js └── socket.js └── vite.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | Dockerfile 4 | .dockerignore 5 | .git 6 | .gitignore 7 | .vscode 8 | .github -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:react/recommended', 7 | 'plugin:react/jsx-runtime', 8 | 'plugin:react-hooks/recommended', 9 | ], 10 | ignorePatterns: ['dist', '.eslintrc.cjs'], 11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 12 | settings: { react: { version: '18.2' } }, 13 | plugins: ['react-refresh'], 14 | rules: { 15 | 'react-refresh/only-export-components': [ 16 | 'warn', 17 | { allowConstantExport: true }, 18 | ], 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: cyfershepard 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Environment Details (please complete the following information):** 14 | - OS: [e.g. Ubuntu] 15 | - Browser [e.g. chrome, Mozilla] 16 | - Jellystat Version [e.g. 1.0.0] 17 | - Jellyfin Version [e.g. 10.0.5] 18 | 19 | 20 | **To Reproduce** 21 | Steps to reproduce the behavior: 22 | 1. Go to '...' 23 | 2. Click on '....' 24 | 3. Scroll down to '....' 25 | 4. See error 26 | 27 | **Expected behavior** 28 | A clear and concise description of what you expected to happen. 29 | 30 | **Screenshots** 31 | If applicable, add screenshots to help explain your problem. 32 | 33 | **Task Logs** 34 | If applicable, add Task Logs for your problem. 35 | 36 | **Container Logs** 37 | If applicable, add Container Logs for your problem. 38 | 39 | 40 | **Additional context** 41 | Add any other context about the problem here. 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker CI 2 | on: 3 | push: 4 | tags: 5 | - "v*.*.*" 6 | branches: [ unstable ] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up Docker Buildx 16 | id: buildx 17 | uses: docker/setup-buildx-action@v3 18 | 19 | - name: Docker meta 20 | id: meta 21 | uses: docker/metadata-action@v5 22 | with: 23 | # list of Docker images to use as base name for tags 24 | images: ${{ github.repository }} 25 | # generate Docker tags based on the following events/attributes 26 | tags: | 27 | type=ref,event=branch 28 | type=ref,event=pr 29 | type=semver,pattern={{version}} 30 | type=semver,pattern={{major}}.{{minor}} 31 | type=semver,pattern={{major}} 32 | 33 | - name: Set up QEMU 34 | uses: docker/setup-qemu-action@v3 35 | 36 | - name: Log in to the Container registry 37 | uses: docker/login-action@v3 38 | with: 39 | username: ${{ secrets.DOCKER_USERNAME }} 40 | password: ${{ secrets.DOCKER_TOKEN }} 41 | 42 | - name: Build Docker image 43 | uses: docker/build-push-action@v5 44 | with: 45 | context: . 46 | push: true 47 | tags: ${{ steps.meta.outputs.tags }} 48 | platforms: linux/amd64,linux/arm64,linux/arm/v7 49 | cache-from: type=gha 50 | cache-to: type=gha,mode=max 51 | -------------------------------------------------------------------------------- /.github/workflows/docker-latest.yml: -------------------------------------------------------------------------------- 1 | name: Docker CI 2 | on: 3 | push: 4 | tags: 5 | - "v*.*.*" 6 | branches: [main] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up Docker Buildx 16 | id: buildx 17 | uses: docker/setup-buildx-action@v3 18 | 19 | - name: Docker meta 20 | id: meta 21 | uses: docker/metadata-action@v5 22 | with: 23 | # list of Docker images to use as base name for tags 24 | images: ${{ github.repository }} 25 | # generate Docker tags based on the following events/attributes 26 | tags: | 27 | type=ref,event=branch 28 | type=ref,event=pr 29 | type=semver,pattern={{version}} 30 | type=semver,pattern={{major}}.{{minor}} 31 | type=semver,pattern={{major}} 32 | 33 | - name: Read version from package.json 34 | id: get_version 35 | run: | 36 | VERSION=$(node -p "require('./package.json').version") 37 | echo "VERSION=$VERSION" >> $GITHUB_ENV 38 | 39 | - name: Set up QEMU 40 | uses: docker/setup-qemu-action@v3 41 | 42 | - name: Log in to the Container registry 43 | uses: docker/login-action@v3 44 | with: 45 | username: ${{ secrets.DOCKER_USERNAME }} 46 | password: ${{ secrets.DOCKER_TOKEN }} 47 | 48 | - name: Build Docker image 49 | uses: docker/build-push-action@v5 50 | with: 51 | context: . 52 | push: true 53 | tags: | 54 | cyfershepard/jellystat:latest 55 | cyfershepard/jellystat:${{ env.VERSION }} 56 | platforms: linux/amd64,linux/arm64,linux/arm/v7 57 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | # This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. 2 | # 3 | # You can adjust the behavior by modifying this file. 4 | # For more information, see: 5 | # https://github.com/actions/stale 6 | name: Mark stale issues and pull requests 7 | 8 | on: 9 | schedule: 10 | - cron: '43 3 * * *' 11 | 12 | jobs: 13 | stale: 14 | 15 | runs-on: ubuntu-latest 16 | permissions: 17 | issues: write 18 | pull-requests: write 19 | 20 | steps: 21 | - uses: actions/stale@v9 22 | with: 23 | repo-token: ${{ secrets.GITHUB_TOKEN }} 24 | stale-issue-message: 'Stale issue message' 25 | stale-pr-message: 'Stale pull request message' 26 | stale-issue-label: 'no-issue-activity' 27 | stale-pr-label: 'no-pr-activity' 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | # dependencies 3 | /node_modules 4 | /.pnp 5 | .pnp.js 6 | 7 | # testing 8 | /coverage 9 | /backend/backup-data 10 | .vscode 11 | .idea 12 | .env 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | .vscode/launch.json 28 | .vscode/launch.json 29 | 30 | # env 31 | /backend/.env 32 | 33 | # local deployment 34 | /dist -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "chrome", 6 | "request": "launch", 7 | "name": "Launch Chrome against localhost", 8 | "url": "http://10.0.0.20:3000", 9 | "webRoot": "${workspaceFolder}" 10 | }, 11 | { 12 | "type": "node-terminal", 13 | "name": "Run Script: start", 14 | "request": "launch", 15 | "command": "npm run start", 16 | "cwd": "${workspaceFolder}" 17 | }, 18 | { 19 | "type": "node-terminal", 20 | "name": "Run Script: start client", 21 | "request": "launch", 22 | "command": "npm run start-client", 23 | "cwd": "${workspaceFolder}" 24 | }, 25 | { 26 | "type": "node-terminal", 27 | "name": "Run Script: start-server", 28 | "request": "launch", 29 | "command": "npm run start-server", 30 | "cwd": "${workspaceFolder}" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build the application 2 | FROM node:slim AS builder 3 | 4 | WORKDIR /app 5 | 6 | COPY package*.json ./ 7 | RUN npm cache clean --force 8 | RUN npm install 9 | 10 | COPY ./ ./ 11 | COPY entry.sh ./ 12 | 13 | # Build the application 14 | RUN npm run build 15 | 16 | # Stage 2: Create the production image 17 | FROM node:slim 18 | 19 | RUN apt-get update && \ 20 | apt-get install -yqq --no-install-recommends wget && \ 21 | apt-get autoremove && \ 22 | apt-get clean && \ 23 | rm -rf /var/lib/apt/lists/* 24 | 25 | WORKDIR /app 26 | 27 | COPY --from=builder /app . 28 | COPY --chmod=755 entry.sh /entry.sh 29 | 30 | HEALTHCHECK --interval=30s \ 31 | --timeout=5s \ 32 | --start-period=10s \ 33 | --retries=3 \ 34 | CMD [ "/usr/bin/wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/auth/isconfigured" ] 35 | 36 | EXPOSE 3000 37 | 38 | CMD ["/entry.sh"] 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Cyfershepard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | POSTGRES_USER = # your postgres username 2 | POSTGRES_PASSWORD = # your postgres password 3 | 4 | POSTGRES_IP = # your postgres IP 5 | POSTGRES_PORT = # your postgres port 6 | 7 | JWT_SECRET = # ultra secret word 8 | 9 | JS_GEOLITE_ACCOUNT_ID = # optional, your GeoLite account ID to show geolocation info for client IPs 10 | JS_GEOLITE_LICENSE_KEY = # optional, your GeoLite account license key to show geolocation info for client IPs -------------------------------------------------------------------------------- /backend/classes/api-loader.js: -------------------------------------------------------------------------------- 1 | const JellyfinAPI = require("./jellyfin-api"); 2 | const EmbyAPI = require("./emby-api"); 3 | 4 | function API() { 5 | const USE_EMBY_API = (process.env.IS_EMBY_API || "false").toLowerCase() === "true"; 6 | if (USE_EMBY_API) { 7 | return new EmbyAPI(); 8 | } else { 9 | return new JellyfinAPI(); 10 | } 11 | } 12 | 13 | module.exports = API(); 14 | -------------------------------------------------------------------------------- /backend/classes/axios.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | const https = require('https'); 3 | const CacheableLookup = require('cacheable-lookup'); 4 | 5 | const cacheable = new CacheableLookup(); 6 | 7 | const agent = new https.Agent({ 8 | rejectUnauthorized: (process.env.REJECT_SELF_SIGNED_CERTIFICATES || 'true').toLowerCase() === 'true' 9 | }); 10 | 11 | cacheable.install(agent); 12 | 13 | const axios_instance = axios.create({ 14 | httpsAgent: agent 15 | }); 16 | 17 | module.exports = 18 | { 19 | axios: axios_instance 20 | }; 21 | -------------------------------------------------------------------------------- /backend/classes/config.js: -------------------------------------------------------------------------------- 1 | const db = require("../db"); 2 | 3 | class Config { 4 | async getConfig() { 5 | try { 6 | //Manual overrides 7 | process.env.POSTGRES_USER = process.env.POSTGRES_USER ?? "postgres"; 8 | 9 | // 10 | const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1'); 11 | 12 | const state = this.#getConfigState(config); 13 | 14 | if (state < 1) { 15 | return { state: 0, error: "Config Details Not Found" }; 16 | } 17 | 18 | return { 19 | JF_HOST: process.env.JF_HOST ?? config[0].JF_HOST, 20 | JF_API_KEY: process.env.JF_API_KEY ?? config[0].JF_API_KEY, 21 | APP_USER: config[0].APP_USER, 22 | APP_PASSWORD: config[0].APP_PASSWORD, 23 | REQUIRE_LOGIN: config[0].REQUIRE_LOGIN, 24 | settings: config[0].settings, 25 | api_keys: config[0].api_keys, 26 | state: state, 27 | IS_JELLYFIN: (process.env.IS_EMBY_API || "false").toLowerCase() === "false", 28 | }; 29 | } catch (error) { 30 | return { error: "Config Details Not Found" }; 31 | } 32 | } 33 | 34 | async getPreferedAdmin() { 35 | const config = await this.getConfig(); 36 | return config.settings?.preferred_admin?.userid; 37 | } 38 | 39 | async getExcludedLibraries() { 40 | const config = await this.getConfig(); 41 | return config.settings?.ExcludedLibraries ?? []; 42 | } 43 | 44 | #getConfigState(Configured) { 45 | let state = 0; 46 | try { 47 | //state 0 = not configured 48 | //state 1 = configured and user set 49 | //state 2 = configured and user and api key set 50 | 51 | if (Configured.length > 0 && Configured[0].APP_USER !== null) { 52 | if ( 53 | Configured[0].JF_API_KEY === null || 54 | (typeof Configured[0].JF_API_KEY === "string" && Configured[0].JF_API_KEY.trim() === "") 55 | ) { 56 | //check if user is configured but API is not configured then return state 1 57 | state = 1; 58 | } else { 59 | //check if user is configured and API is configured then return state 2 60 | state = 2; 61 | } 62 | } 63 | return state; 64 | } catch (error) { 65 | return state; 66 | } 67 | } 68 | } 69 | 70 | module.exports = Config; 71 | -------------------------------------------------------------------------------- /backend/classes/env.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | 4 | async function writeEnvVariables() { 5 | //Define sensitive variables that should not be exposed 6 | const excludedVariables = ["JS_GEOLITE_LICENSE_KEY", "JS_USER", "JS_PASSWORD"]; 7 | // Fetch environment variables that start with JS_ 8 | const envVariables = Object.keys(process.env).reduce((acc, key) => { 9 | if (key.startsWith("JS_") && !excludedVariables.includes(key)) { 10 | acc[key] = process.env[key]; 11 | } 12 | return acc; 13 | }, {}); 14 | 15 | // Convert the environment variables to a JavaScript object string 16 | const envContent = `window.env = ${JSON.stringify(envVariables, null, 2)};`; 17 | 18 | // Define the output file path 19 | const outputPath = path.join(__dirname, "..", "..", "dist", "env.js"); 20 | 21 | // Write the environment variables to the file 22 | fs.writeFile(outputPath, envContent, "utf8", (err) => { 23 | if (err) { 24 | console.error("Error writing env.js file:", err); 25 | } else { 26 | console.log("env.js file has been saved successfully."); 27 | } 28 | }); 29 | } 30 | 31 | module.exports = writeEnvVariables; 32 | -------------------------------------------------------------------------------- /backend/classes/logging.js: -------------------------------------------------------------------------------- 1 | const db = require("../db"); 2 | const moment = require("moment"); 3 | const taskstate = require("../logging/taskstate"); 4 | 5 | const { jf_logging_columns, jf_logging_mapping } = require("../models/jf_logging"); 6 | 7 | async function insertLog(uuid, triggertype, taskType) { 8 | try { 9 | let startTime = moment(); 10 | const log = { 11 | Id: uuid, 12 | Name: taskType, 13 | Type: "Task", 14 | ExecutionType: triggertype, 15 | Duration: 0, 16 | TimeRun: startTime, 17 | Log: JSON.stringify([{}]), 18 | Result: taskstate.RUNNING, 19 | }; 20 | 21 | await db.insertBulk("jf_logging", log, jf_logging_columns); 22 | } catch (error) { 23 | console.log(error); 24 | return []; 25 | } 26 | } 27 | 28 | async function updateLog(uuid, data, taskstate) { 29 | try { 30 | const { rows: task } = await db.query(`SELECT "TimeRun" FROM jf_logging WHERE "Id" = '${uuid}';`); 31 | 32 | if (task.length === 0) { 33 | console.log("Unable to find task to update"); 34 | } else { 35 | let endtime = moment(); 36 | let startTime = moment(task[0].TimeRun); 37 | let duration = endtime.diff(startTime, "seconds"); 38 | const log = { 39 | Id: uuid, 40 | Name: "NULL Placeholder", 41 | Type: "Task", 42 | ExecutionType: "NULL Placeholder", 43 | Duration: duration, 44 | TimeRun: startTime, 45 | Log: JSON.stringify(data), 46 | Result: taskstate, 47 | }; 48 | 49 | await db.insertBulk("jf_logging", log, jf_logging_columns); 50 | } 51 | } catch (error) { 52 | console.log(error); 53 | return []; 54 | } 55 | } 56 | 57 | module.exports = { 58 | insertLog, 59 | updateLog, 60 | }; 61 | -------------------------------------------------------------------------------- /backend/classes/task-manager-singleton.js: -------------------------------------------------------------------------------- 1 | const TaskManager = require("./task-manager"); 2 | 3 | class TaskManagerSingleton { 4 | constructor() { 5 | if (!TaskManagerSingleton.instance) { 6 | TaskManagerSingleton.instance = new TaskManager(); 7 | console.log("Task Manager Singleton created"); 8 | } 9 | } 10 | 11 | getInstance() { 12 | return TaskManagerSingleton.instance; 13 | } 14 | } 15 | 16 | module.exports = TaskManagerSingleton; 17 | -------------------------------------------------------------------------------- /backend/classes/task-scheduler-singleton.js: -------------------------------------------------------------------------------- 1 | const TaskScheduler = require("./task-scheduler.js"); 2 | 3 | class TaskSchedulerSingleton { 4 | constructor() { 5 | if (!TaskSchedulerSingleton.instance) { 6 | TaskSchedulerSingleton.instance = new TaskScheduler(); 7 | console.log("Task Scheduler Singleton created"); 8 | } 9 | } 10 | 11 | getInstance() { 12 | return TaskSchedulerSingleton.instance; 13 | } 14 | } 15 | 16 | module.exports = TaskSchedulerSingleton; 17 | -------------------------------------------------------------------------------- /backend/create_database.js: -------------------------------------------------------------------------------- 1 | const { Client } = require('pg'); 2 | 3 | const _POSTGRES_USER = process.env.POSTGRES_USER; 4 | const _POSTGRES_PASSWORD = process.env.POSTGRES_PASSWORD; 5 | const _POSTGRES_IP = process.env.POSTGRES_IP; 6 | const _POSTGRES_PORT = process.env.POSTGRES_PORT; 7 | const _POSTGRES_DATABASE = process.env.POSTGRES_DB || 'jfstat'; 8 | 9 | const client = new Client({ 10 | host: _POSTGRES_IP, 11 | user: _POSTGRES_USER, 12 | password: _POSTGRES_PASSWORD, 13 | port: _POSTGRES_PORT, 14 | }); 15 | 16 | const createDatabase = async () => { 17 | try { 18 | await client.connect(); // gets connection 19 | await client.query('CREATE DATABASE ' + _POSTGRES_DATABASE); // sends queries 20 | return true; 21 | } catch (error) { 22 | if (!error.stack.includes('already exists')) { 23 | console.error(error.stack); 24 | } 25 | 26 | return false; 27 | } finally { 28 | await client.end(); // closes connection 29 | } 30 | }; 31 | 32 | module.exports = { 33 | createDatabase: createDatabase, 34 | }; 35 | -------------------------------------------------------------------------------- /backend/global/backup_tables.js: -------------------------------------------------------------------------------- 1 | const tables = [ 2 | { value: "jf_libraries", name: "Libraries" }, 3 | { value: "jf_library_items", name: "Library Items" }, 4 | { value: "jf_library_seasons", name: "Seasons" }, 5 | { value: "jf_library_episodes", name: "Episodes" }, 6 | { value: "jf_users", name: "Users" }, 7 | { value: "jf_playback_activity", name: "Activity" }, 8 | { value: "jf_playback_reporting_plugin_data", name: "Playback Reporting Plugin Data" }, 9 | { value: "jf_item_info", name: "Item Info" }, 10 | ]; 11 | 12 | module.exports = { tables }; 13 | -------------------------------------------------------------------------------- /backend/global/task-list.js: -------------------------------------------------------------------------------- 1 | const TaskName = require("../logging/taskName"); 2 | 3 | const Tasks = { 4 | Backup: { path: "./tasks/BackupTask.js", name: TaskName.backup }, 5 | Restore: { path: "./tasks/BackupTask.js", name: TaskName.restore }, 6 | JellyfinSync: { path: "./tasks/FullSyncTask.js", name: TaskName.fullsync }, 7 | PartialJellyfinSync: { path: "./tasks/RecentlyAddedItemsSyncTask.js", name: TaskName.partialsync }, 8 | JellyfinPlaybackReportingPluginSync: { path: "./tasks/PlaybackReportingPluginSyncTask.js", name: TaskName.import }, 9 | // Add more tasks as needed 10 | }; 11 | 12 | module.exports = Tasks; 13 | -------------------------------------------------------------------------------- /backend/logging/taskName.js: -------------------------------------------------------------------------------- 1 | const task = { 2 | fullsync: 'Full Jellyfin Sync', 3 | partialsync: 'Recently Added Sync', 4 | backup: 'Backup', 5 | restore: 'Restore', 6 | import: 'Jellyfin Playback Reporting Plugin Sync', 7 | }; 8 | 9 | module.exports = task; -------------------------------------------------------------------------------- /backend/logging/taskstate.js: -------------------------------------------------------------------------------- 1 | const taskstate = { 2 | SUCCESS: 'Success', 3 | FAILED: 'Failed', 4 | RUNNING: 'Running', 5 | }; 6 | 7 | module.exports = taskstate; -------------------------------------------------------------------------------- /backend/logging/triggertype.js: -------------------------------------------------------------------------------- 1 | const triggertype = { 2 | Automatic: 'Automatic', 3 | Manual: 'Manual' 4 | }; 5 | 6 | module.exports = triggertype; -------------------------------------------------------------------------------- /backend/migrations.js: -------------------------------------------------------------------------------- 1 | process.env.POSTGRES_USER = process.env.POSTGRES_USER ?? "postgres"; 2 | process.env.POSTGRES_ROLE = 3 | process.env.POSTGRES_ROLE ?? process.env.POSTGRES_USER; 4 | 5 | module.exports = { 6 | development: { 7 | client: 'pg', 8 | connection: { 9 | host: process.env.POSTGRES_IP, 10 | user: process.env.POSTGRES_USER, 11 | password: process.env.POSTGRES_PASSWORD, 12 | port:process.env.POSTGRES_PORT, 13 | database: process.env.POSTGRES_DB || 'jfstat', 14 | createDatabase: true, 15 | }, 16 | migrations: { 17 | directory: __dirname + '/migrations', 18 | migrationSource: { 19 | // Use a sequential index naming convention for migration files 20 | pattern: /^([0-9]+)_.*\.js$/, 21 | sortDirsSeparately: true, 22 | load: (filename) => { 23 | const migration = require(filename); 24 | if (migration.up && migration.down) { 25 | return migration; 26 | } else { 27 | throw new Error(`Invalid migration file: ${filename}`); 28 | } 29 | } 30 | } 31 | } 32 | }, 33 | production: { 34 | client: 'pg', 35 | connection: { 36 | host: process.env.DB_HOST, 37 | user: process.env.DB_USER, 38 | password: process.env.DB_PASSWORD, 39 | port:process.env.POSTGRES_PORT, 40 | database: process.env.POSTGRES_DB || 'jfstat', 41 | createDatabase: true, 42 | }, 43 | migrations: { 44 | directory: __dirname + '/migrations', 45 | migrationSource: { 46 | // Use a sequential index naming convention for migration files 47 | pattern: /^([0-9]+)_.*\.js$/, 48 | sortDirsSeparately: true, 49 | load: (filename) => { 50 | const migration = require(filename); 51 | if (migration.up && migration.down) { 52 | return migration; 53 | } else { 54 | throw new Error(`Invalid migration file: ${filename}`); 55 | } 56 | } 57 | } 58 | } 59 | } 60 | }; 61 | 62 | -------------------------------------------------------------------------------- /backend/migrations/001_app_config_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try 3 | { 4 | const hasTable = await knex.schema.hasTable('app_config'); 5 | if (!hasTable) { 6 | await knex.schema.createTable('app_config', function(table) { 7 | table.increments('ID').primary(); 8 | table.text('JF_HOST'); 9 | table.text('JF_API_KEY'); 10 | table.text('APP_USER'); 11 | table.text('APP_PASSWORD'); 12 | }); 13 | await knex.raw(`ALTER TABLE app_config OWNER TO "${process.env.POSTGRES_ROLE}";`); 14 | } 15 | }catch (error) { 16 | console.error(error); 17 | } 18 | }; 19 | 20 | exports.down = async function(knex) { 21 | try { 22 | await knex.schema.dropTableIfExists('app_config'); 23 | } catch (error) { 24 | console.error(error); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /backend/migrations/002_jf_activity_watchdog_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try { 3 | const hasTable = await knex.schema.hasTable('jf_activity_watchdog'); 4 | if (!hasTable) { 5 | await knex.schema.createTable('jf_activity_watchdog', function(table) { 6 | table.text('Id').primary(); 7 | table.boolean('IsPaused').defaultTo(false); 8 | table.text('UserId'); 9 | table.text('UserName'); 10 | table.text('Client'); 11 | table.text('DeviceName'); 12 | table.text('DeviceId'); 13 | table.text('ApplicationVersion'); 14 | table.text('NowPlayingItemId'); 15 | table.text('NowPlayingItemName'); 16 | table.text('SeasonId'); 17 | table.text('SeriesName'); 18 | table.text('EpisodeId'); 19 | table.bigInteger('PlaybackDuration'); 20 | table.timestamp('ActivityDateInserted').defaultTo(knex.fn.now()); 21 | table.text('PlayMethod'); 22 | }); 23 | await knex.raw(`ALTER TABLE jf_activity_watchdog OWNER TO "${process.env.POSTGRES_ROLE}";`); 24 | } 25 | } catch (error) { 26 | console.error(error); 27 | } 28 | }; 29 | 30 | exports.down = async function(knex) { 31 | try { 32 | await knex.schema.dropTableIfExists('jf_activity_watchdog'); 33 | } catch (error) { 34 | console.error(error); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /backend/migrations/003_jf_libraries_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try { 3 | const hasTable = await knex.schema.hasTable('jf_libraries'); 4 | if (!hasTable) { 5 | await knex.schema.createTable('jf_libraries', function(table) { 6 | table.text('Id').primary(); 7 | table.text('Name').notNullable(); 8 | table.text('ServerId'); 9 | table.boolean('IsFolder').notNullable().defaultTo(true); 10 | table.text('Type').notNullable().defaultTo('CollectionFolder'); 11 | table.text('CollectionType').notNullable(); 12 | table.text('ImageTagsPrimary'); 13 | }); 14 | await knex.raw(`ALTER TABLE jf_libraries OWNER TO "${process.env.POSTGRES_ROLE}";`); 15 | } 16 | } catch (error) { 17 | console.error(error); 18 | } 19 | }; 20 | 21 | exports.down = async function(knex) { 22 | try { 23 | await knex.schema.dropTableIfExists('jf_libraries'); 24 | } catch (error) { 25 | console.error(error); 26 | } 27 | }; 28 | 29 | -------------------------------------------------------------------------------- /backend/migrations/004_jf_library_items_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try { 3 | const hasTable = await knex.schema.hasTable('jf_library_items'); 4 | if (!hasTable) { 5 | await knex.schema.createTable('jf_library_items', function(table) { 6 | table.text('Id').primary(); 7 | table.text('Name').notNullable(); 8 | table.text('ServerId'); 9 | table.timestamp('PremiereDate'); 10 | table.timestamp('EndDate'); 11 | table.double('CommunityRating'); 12 | table.bigInteger('RunTimeTicks'); 13 | table.integer('ProductionYear'); 14 | table.boolean('IsFolder'); 15 | table.text('Type').notNullable(); 16 | table.text('Status'); 17 | table.text('ImageTagsPrimary'); 18 | table.text('ImageTagsBanner'); 19 | table.text('ImageTagsLogo'); 20 | table.text('ImageTagsThumb'); 21 | table.text('BackdropImageTags'); 22 | table.text('ParentId').notNullable().references('Id').inTable('jf_libraries').onDelete('SET NULL').onUpdate('NO ACTION'); 23 | table.text('PrimaryImageHash'); 24 | }); 25 | await knex.raw(`ALTER TABLE IF EXISTS jf_library_items OWNER TO "${process.env.POSTGRES_ROLE}";`); 26 | 27 | } 28 | } catch (error) { 29 | console.error(error); 30 | } 31 | }; 32 | 33 | exports.down = async function(knex) { 34 | try { 35 | await knex.schema.dropTableIfExists('jf_library_items'); 36 | } catch (error) { 37 | console.error(error); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /backend/migrations/005_jf_library_seasons_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try { 3 | const tableExists = await knex.schema.hasTable('jf_library_seasons'); 4 | if (!tableExists) { 5 | await knex.schema.createTable('jf_library_seasons', function(table) { 6 | table.text('Id').notNullable().primary(); 7 | table.text('Name'); 8 | table.text('ServerId'); 9 | table.integer('IndexNumber'); 10 | table.text('Type'); 11 | table.text('ParentLogoItemId'); 12 | table.text('ParentBackdropItemId'); 13 | table.text('ParentBackdropImageTags'); 14 | table.text('SeriesName'); 15 | table.text('SeriesId'); 16 | table.text('SeriesPrimaryImageTag'); 17 | }); 18 | 19 | await knex.raw(`ALTER TABLE IF EXISTS jf_library_seasons OWNER TO "${process.env.POSTGRES_ROLE}";`); 20 | } 21 | } catch (error) { 22 | console.error(error); 23 | } 24 | }; 25 | 26 | exports.down = async function(knex) { 27 | try { 28 | await knex.schema.dropTableIfExists('jf_library_seasons'); 29 | } catch (error) { 30 | console.error(error); 31 | } 32 | }; 33 | 34 | -------------------------------------------------------------------------------- /backend/migrations/006_jf_library_episodes_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try { 3 | const tableExists = await knex.schema.hasTable('jf_library_episodes'); 4 | if (!tableExists) { 5 | await knex.schema.createTable('jf_library_episodes', function(table) { 6 | table.text('Id').primary(); 7 | table.text('EpisodeId').notNullable(); 8 | table.text('Name'); 9 | table.text('ServerId'); 10 | table.timestamp('PremiereDate'); 11 | table.text('OfficialRating'); 12 | table.double('CommunityRating'); 13 | table.bigInteger('RunTimeTicks'); 14 | table.integer('ProductionYear'); 15 | table.integer('IndexNumber'); 16 | table.integer('ParentIndexNumber'); 17 | table.text('Type'); 18 | table.text('ParentLogoItemId'); 19 | table.text('ParentBackdropItemId'); 20 | table.text('ParentBackdropImageTags'); 21 | table.text('SeriesId'); 22 | table.text('SeasonId'); 23 | table.text('SeasonName'); 24 | table.text('SeriesName'); 25 | }); 26 | 27 | await knex.raw(`ALTER TABLE IF EXISTS jf_library_episodes OWNER TO "${process.env.POSTGRES_ROLE}";`); 28 | } 29 | } catch (error) { 30 | console.error(error); 31 | } 32 | }; 33 | 34 | 35 | // exports.down = function(knex) { 36 | // return knex.schema.hasTable('jf_library_episodes').then(function(exists) { 37 | // if (exists) { 38 | // return knex.schema.dropTable('jf_library_episodes'); 39 | // } 40 | // }).catch(function(error) { 41 | // console.error(error); 42 | // }); 43 | // }; 44 | 45 | exports.down = async function(knex) { 46 | try { 47 | await knex.schema.dropTableIfExists('jf_library_episodes'); 48 | } catch (error) { 49 | console.error(error); 50 | } 51 | }; 52 | 53 | -------------------------------------------------------------------------------- /backend/migrations/007_jf_playback_activity_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try { 3 | const tableExists = await knex.schema.hasTable('jf_playback_activity'); 4 | if (!tableExists) { 5 | await knex.schema.createTable('jf_playback_activity', function(table) { 6 | table.text('Id').notNullable().primary(); 7 | table.boolean('IsPaused').defaultTo(false); 8 | table.text('UserId'); 9 | table.text('UserName'); 10 | table.text('Client'); 11 | table.text('DeviceName'); 12 | table.text('DeviceId'); 13 | table.text('ApplicationVersion'); 14 | table.text('NowPlayingItemId'); 15 | table.text('NowPlayingItemName'); 16 | table.text('SeasonId'); 17 | table.text('SeriesName'); 18 | table.text('EpisodeId'); 19 | table.bigInteger('PlaybackDuration'); 20 | table.timestamp('ActivityDateInserted').defaultTo(knex.fn.now()); 21 | table.text('PlayMethod'); 22 | }); 23 | 24 | await knex.raw(`ALTER TABLE IF EXISTS jf_playback_activity OWNER TO "${process.env.POSTGRES_ROLE}";`); 25 | } 26 | } catch (error) { 27 | console.error(error); 28 | } 29 | }; 30 | 31 | exports.down = async function(knex) { 32 | try { 33 | await knex.schema.dropTableIfExists('jf_playback_activity'); 34 | } catch (error) { 35 | console.error(error); 36 | } 37 | }; 38 | 39 | -------------------------------------------------------------------------------- /backend/migrations/008_js_users_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try { 3 | const tableExists = await knex.schema.hasTable('jf_users'); 4 | if (!tableExists) { 5 | await knex.schema.createTable('jf_users', function(table) { 6 | table.text('Id').primary().notNullable().collate('default'); 7 | table.text('Name').collate('default'); 8 | table.text('PrimaryImageTag').collate('default'); 9 | table.timestamp('LastLoginDate', { useTz: true }); 10 | table.timestamp('LastActivityDate', { useTz: true }); 11 | table.boolean('IsAdministrator'); 12 | }); 13 | 14 | await knex.raw(`ALTER TABLE IF EXISTS jf_users OWNER TO "${process.env.POSTGRES_ROLE}";`);; 15 | } 16 | } catch (error) { 17 | console.error(error); 18 | } 19 | }; 20 | 21 | exports.down = async function(knex) { 22 | try { 23 | await knex.schema.dropTableIfExists('jf_users'); 24 | } catch (error) { 25 | console.error(error); 26 | } 27 | }; 28 | 29 | 30 | -------------------------------------------------------------------------------- /backend/migrations/010_jf_library_count_view.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.raw(` 3 | CREATE OR REPLACE VIEW jf_library_count_view 4 | AS 5 | SELECT l."Id", 6 | l."Name", 7 | l."CollectionType", 8 | count(DISTINCT i."Id") AS "Library_Count", 9 | count(DISTINCT s."Id") AS "Season_Count", 10 | count(DISTINCT e."Id") AS "Episode_Count" 11 | FROM jf_libraries l 12 | JOIN jf_library_items i ON i."ParentId" = l."Id" 13 | LEFT JOIN jf_library_seasons s ON s."SeriesId" = i."Id" 14 | LEFT JOIN jf_library_episodes e ON e."SeasonId" = s."Id" 15 | GROUP BY l."Id", l."Name" 16 | ORDER BY (count(DISTINCT i."Id")) DESC; 17 | `).catch(function(error) { 18 | console.error(error); 19 | }); 20 | }; 21 | 22 | exports.down = function(knex) { 23 | return knex.raw(` 24 | DROP VIEW jf_library_count_view; 25 | `); 26 | }; 27 | -------------------------------------------------------------------------------- /backend/migrations/012_fs_last_library_activity_function.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema.raw(` 3 | CREATE OR REPLACE FUNCTION fs_last_library_activity( 4 | libraryid text) 5 | RETURNS TABLE("Id" text, "Name" text, "EpisodeName" text, "SeasonNumber" integer, "EpisodeNumber" integer, "PrimaryImageHash" text, "UserId" text, "UserName" text, "LastPlayed" interval) 6 | LANGUAGE 'plpgsql' 7 | COST 100 8 | VOLATILE PARALLEL UNSAFE 9 | ROWS 1000 10 | 11 | AS $BODY$ 12 | BEGIN 13 | RETURN QUERY 14 | SELECT * 15 | FROM ( 16 | SELECT DISTINCT ON (i."Name", e."Name") 17 | i."Id", 18 | i."Name", 19 | e."Name" AS "EpisodeName", 20 | CASE WHEN a."SeasonId" IS NOT NULL THEN s."IndexNumber" ELSE NULL END AS "SeasonNumber", 21 | CASE WHEN a."SeasonId" IS NOT NULL THEN e."IndexNumber" ELSE NULL END AS "EpisodeNumber", 22 | i."PrimaryImageHash", 23 | a."UserId", 24 | a."UserName", 25 | (NOW() - a."ActivityDateInserted") as "LastPlayed" 26 | FROM jf_playback_activity a 27 | JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId" 28 | JOIN jf_libraries l ON i."ParentId" = l."Id" 29 | LEFT JOIN jf_library_seasons s ON s."Id" = a."SeasonId" 30 | LEFT JOIN jf_library_episodes e ON e."EpisodeId" = a."EpisodeId" 31 | WHERE l."Id" = libraryid 32 | ORDER BY i."Name", e."Name", a."ActivityDateInserted" DESC 33 | ) AS latest_distinct_rows 34 | ORDER BY "LastPlayed" 35 | LIMIT 15; 36 | END; 37 | $BODY$; 38 | `).catch(function(error) { 39 | console.error(error); 40 | }); 41 | }; 42 | 43 | exports.down = function(knex) { 44 | return knex.schema.raw(` 45 | DROP FUNCTION IF EXISTS fs_last_library_activity(text); 46 | `); 47 | }; 48 | -------------------------------------------------------------------------------- /backend/migrations/013_fs_last_user_activity_function.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.raw(` 3 | CREATE OR REPLACE FUNCTION fs_last_user_activity( 4 | userid text 5 | ) 6 | RETURNS TABLE( 7 | "Id" text, 8 | "Name" text, 9 | "EpisodeName" text, 10 | "SeasonNumber" integer, 11 | "EpisodeNumber" integer, 12 | "PrimaryImageHash" text, 13 | "UserId" text, 14 | "UserName" text, 15 | "LastPlayed" interval 16 | ) 17 | LANGUAGE 'plpgsql' 18 | COST 100 19 | VOLATILE PARALLEL UNSAFE 20 | ROWS 1000 21 | AS $BODY$ 22 | BEGIN 23 | RETURN QUERY 24 | SELECT * 25 | FROM ( 26 | SELECT DISTINCT ON (i."Name", e."Name") 27 | i."Id", 28 | i."Name", 29 | e."Name" AS "EpisodeName", 30 | CASE WHEN a."SeasonId" IS NOT NULL THEN s."IndexNumber" ELSE NULL END AS "SeasonNumber", 31 | CASE WHEN a."SeasonId" IS NOT NULL THEN e."IndexNumber" ELSE NULL END AS "EpisodeNumber", 32 | i."PrimaryImageHash", 33 | a."UserId", 34 | a."UserName", 35 | (NOW() - a."ActivityDateInserted") as "LastPlayed" 36 | FROM jf_playback_activity a 37 | JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId" 38 | LEFT JOIN jf_library_seasons s ON s."Id" = a."SeasonId" 39 | LEFT JOIN jf_library_episodes e ON e."EpisodeId" = a."EpisodeId" 40 | WHERE a."UserId" = userid 41 | ) AS latest_distinct_rows 42 | ORDER BY "LastPlayed"; 43 | END; 44 | $BODY$; 45 | 46 | ALTER FUNCTION fs_last_user_activity(text) 47 | OWNER TO "${process.env.POSTGRES_ROLE}"; 48 | `).catch(function(error) { 49 | console.error(error); 50 | }); 51 | }; 52 | 53 | exports.down = function(knex) { 54 | return knex.raw(` 55 | DROP FUNCTION IF EXISTS fs_last_user_activity(text); 56 | `); 57 | }; 58 | 59 | -------------------------------------------------------------------------------- /backend/migrations/014_fs_library_stats_function.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.raw(` 3 | CREATE OR REPLACE FUNCTION fs_library_stats( 4 | hours integer, 5 | libraryid text) 6 | RETURNS TABLE("Plays" bigint, total_playback_duration numeric, "Id" text, "Name" text) 7 | LANGUAGE 'plpgsql' 8 | COST 100 9 | VOLATILE PARALLEL UNSAFE 10 | ROWS 1000 11 | AS $BODY$ 12 | BEGIN 13 | RETURN QUERY 14 | SELECT count(*) AS "Plays", 15 | sum(a."PlaybackDuration") AS total_playback_duration, 16 | l."Id", 17 | l."Name" 18 | FROM jf_playback_activity a 19 | join jf_library_items i 20 | on a."NowPlayingItemId"=i."Id" 21 | join jf_libraries l 22 | on i."ParentId"=l."Id" 23 | WHERE a."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(hours => hours) AND NOW() 24 | and l."Id"=libraryid 25 | GROUP BY l."Id", l."Name" 26 | ORDER BY (count(*)) DESC; 27 | END; 28 | $BODY$; 29 | 30 | ALTER FUNCTION fs_library_stats(integer, text) 31 | OWNER TO "${process.env.POSTGRES_ROLE}"; 32 | `).catch(function(error) { 33 | console.error(error); 34 | }); 35 | }; 36 | 37 | exports.down = async function(knex) { 38 | await knex.raw(` 39 | DROP FUNCTION IF EXISTS fs_library_stats(integer, text); 40 | `); 41 | }; 42 | 43 | -------------------------------------------------------------------------------- /backend/migrations/015_fs_most_active_user_function.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.raw(` 3 | CREATE OR REPLACE FUNCTION fs_most_active_user( 4 | days integer) 5 | RETURNS TABLE("Plays" bigint, "UserId" text, "Name" text) 6 | LANGUAGE 'plpgsql' 7 | COST 100 8 | VOLATILE PARALLEL UNSAFE 9 | ROWS 1000 10 | AS $BODY$ 11 | BEGIN 12 | RETURN QUERY 13 | SELECT count(*) AS "Plays", 14 | jf_playback_activity."UserId", 15 | jf_playback_activity."UserName" AS "Name" 16 | FROM jf_playback_activity 17 | WHERE jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(days => days) AND NOW() 18 | GROUP BY jf_playback_activity."UserId", jf_playback_activity."UserName" 19 | ORDER BY (count(*)) DESC; 20 | END; 21 | $BODY$; 22 | ALTER FUNCTION fs_most_active_user(integer) 23 | OWNER TO "${process.env.POSTGRES_ROLE}"; 24 | `).catch(function(error) { 25 | console.error(error); 26 | }); 27 | }; 28 | 29 | exports.down = function(knex) { 30 | return knex.raw('DROP FUNCTION IF EXISTS fs_most_active_user(integer)'); 31 | }; 32 | 33 | -------------------------------------------------------------------------------- /backend/migrations/016_fs_most_played_items_function.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.raw(` 3 | CREATE OR REPLACE FUNCTION fs_most_played_items( 4 | days integer, 5 | itemtype text 6 | ) 7 | RETURNS TABLE( 8 | "Plays" bigint, 9 | total_playback_duration numeric, 10 | "Name" text, 11 | "Id" text, 12 | "PrimaryImageHash" text 13 | ) 14 | LANGUAGE 'plpgsql' 15 | COST 100 16 | VOLATILE PARALLEL UNSAFE 17 | ROWS 1000 18 | AS $BODY$ 19 | BEGIN 20 | RETURN QUERY 21 | SELECT 22 | t.plays, 23 | t.total_playback_duration, 24 | i."Name", 25 | i."Id", 26 | i."PrimaryImageHash" 27 | FROM ( 28 | SELECT 29 | count(*) AS plays, 30 | sum(jf_playback_activity."PlaybackDuration") AS total_playback_duration, 31 | jf_playback_activity."NowPlayingItemId" 32 | FROM 33 | jf_playback_activity 34 | WHERE 35 | jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(days => days) and NOW() 36 | GROUP BY 37 | jf_playback_activity."NowPlayingItemId" 38 | ORDER BY 39 | count(*) DESC 40 | ) t 41 | JOIN jf_library_items i 42 | ON t."NowPlayingItemId" = i."Id" 43 | AND i."Type" = itemtype 44 | ORDER BY 45 | t.plays DESC; 46 | END; 47 | $BODY$; 48 | 49 | ALTER FUNCTION fs_most_played_items(integer, text) 50 | OWNER TO "${process.env.POSTGRES_ROLE}"; 51 | `).catch(function(error) { 52 | console.error(error); 53 | }); 54 | }; 55 | 56 | exports.down = async function(knex) { 57 | await knex.raw('DROP FUNCTION IF EXISTS fs_most_played_items(integer, text)'); 58 | }; 59 | 60 | -------------------------------------------------------------------------------- /backend/migrations/018_fs_most_used_clients_function.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.raw(` 3 | CREATE OR REPLACE FUNCTION fs_most_used_clients( 4 | days integer 5 | ) 6 | RETURNS TABLE("Plays" bigint, "Client" text) 7 | LANGUAGE 'plpgsql' 8 | COST 100 9 | VOLATILE PARALLEL UNSAFE 10 | ROWS 1000 11 | 12 | AS $BODY$ 13 | BEGIN 14 | RETURN QUERY 15 | SELECT count(*) AS "Plays", 16 | jf_playback_activity."Client" 17 | FROM jf_playback_activity 18 | WHERE jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(days => days) AND NOW() 19 | GROUP BY jf_playback_activity."Client" 20 | ORDER BY (count(*)) DESC; 21 | END; 22 | $BODY$; 23 | 24 | ALTER FUNCTION fs_most_used_clients(integer) 25 | OWNER TO "${process.env.POSTGRES_ROLE}"; 26 | `).catch(function(error) { 27 | console.error(error); 28 | }); 29 | }; 30 | 31 | exports.down = async function(knex) { 32 | await knex.raw(`DROP FUNCTION fs_most_used_clients(integer);`); 33 | }; 34 | 35 | -------------------------------------------------------------------------------- /backend/migrations/019_fs_most_viewed_libraries_function.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.raw(` 3 | CREATE OR REPLACE FUNCTION fs_most_viewed_libraries( 4 | days integer 5 | ) RETURNS TABLE( 6 | "Plays" numeric, 7 | "Id" text, 8 | "Name" text, 9 | "ServerId" text, 10 | "IsFolder" boolean, 11 | "Type" text, 12 | "CollectionType" text, 13 | "ImageTagsPrimary" text 14 | ) 15 | LANGUAGE 'plpgsql' 16 | COST 100 17 | VOLATILE PARALLEL UNSAFE 18 | ROWS 1000 19 | AS $BODY$ 20 | BEGIN 21 | RETURN QUERY 22 | SELECT 23 | sum(t."Plays"), 24 | l."Id", 25 | l."Name", 26 | l."ServerId", 27 | l."IsFolder", 28 | l."Type", 29 | l."CollectionType", 30 | l."ImageTagsPrimary" 31 | FROM ( 32 | SELECT count(*) AS "Plays", 33 | sum(jf_playback_activity."PlaybackDuration") AS "TotalPlaybackDuration", 34 | jf_playback_activity."NowPlayingItemId" 35 | FROM jf_playback_activity 36 | WHERE 37 | jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(days => days) and NOW() 38 | 39 | GROUP BY jf_playback_activity."NowPlayingItemId" 40 | ORDER BY "Plays" DESC 41 | ) t 42 | JOIN jf_library_items i 43 | ON i."Id" = t."NowPlayingItemId" 44 | JOIN jf_libraries l 45 | ON l."Id" = i."ParentId" 46 | GROUP BY 47 | l."Id" 48 | ORDER BY 49 | (sum( t."Plays")) DESC; 50 | END; 51 | $BODY$; 52 | 53 | ALTER FUNCTION fs_most_viewed_libraries(integer) 54 | OWNER TO "${process.env.POSTGRES_ROLE}"; 55 | `).catch(function(error) { 56 | console.error(error); 57 | }); 58 | }; 59 | 60 | exports.down = function(knex) { 61 | return knex.raw(` 62 | DROP FUNCTION IF EXISTS fs_most_viewed_libraries(integer); 63 | `); 64 | }; 65 | 66 | -------------------------------------------------------------------------------- /backend/migrations/020_fs_user_stats_function.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.raw(` 3 | CREATE OR REPLACE FUNCTION fs_user_stats( 4 | hours integer, 5 | userid text 6 | ) 7 | RETURNS TABLE( 8 | "Plays" bigint, 9 | total_playback_duration numeric, 10 | "UserId" text, 11 | "Name" text 12 | ) 13 | LANGUAGE 'plpgsql' 14 | COST 100 15 | VOLATILE PARALLEL UNSAFE 16 | ROWS 1000 17 | AS $BODY$ 18 | BEGIN 19 | RETURN QUERY 20 | SELECT 21 | count(*) AS "Plays", 22 | sum(jf_playback_activity."PlaybackDuration") AS total_playback_duration, 23 | jf_playback_activity."UserId", 24 | jf_playback_activity."UserName" AS "Name" 25 | FROM jf_playback_activity 26 | WHERE 27 | jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - INTERVAL '1 hour' * hours AND NOW() 28 | AND jf_playback_activity."UserId" = userid 29 | GROUP BY jf_playback_activity."UserId", jf_playback_activity."UserName" 30 | ORDER BY count(*) DESC; 31 | END; 32 | $BODY$; 33 | 34 | ALTER FUNCTION fs_user_stats(integer, text) 35 | OWNER TO "${process.env.POSTGRES_ROLE}"; 36 | `).catch(function(error) { 37 | console.error(error); 38 | }); 39 | }; 40 | 41 | exports.down = async function(knex) { 42 | await knex.raw(`DROP FUNCTION IF EXISTS fs_user_stats(integer, text);`); 43 | }; 44 | -------------------------------------------------------------------------------- /backend/migrations/021_fs_watch_stats_over_time_functions.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | await knex.raw(` 3 | CREATE OR REPLACE FUNCTION fs_watch_stats_over_time( 4 | days integer 5 | ) 6 | RETURNS TABLE( 7 | "Date" date, 8 | "Count" bigint, 9 | "Library" text 10 | ) 11 | LANGUAGE 'plpgsql' 12 | COST 100 13 | VOLATILE PARALLEL UNSAFE 14 | ROWS 1000 15 | 16 | AS $BODY$ 17 | BEGIN 18 | RETURN QUERY 19 | SELECT 20 | dates."Date", 21 | COALESCE(counts."Count", 0) AS "Count", 22 | l."Name" as "Library" 23 | FROM 24 | (SELECT generate_series( 25 | DATE_TRUNC('day', NOW() - CAST(days || ' days' as INTERVAL)), 26 | DATE_TRUNC('day', NOW()), 27 | '1 day')::DATE AS "Date" 28 | ) dates 29 | CROSS JOIN jf_libraries l 30 | LEFT JOIN 31 | (SELECT 32 | DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date", 33 | COUNT(*) AS "Count", 34 | l."Name" as "Library" 35 | FROM 36 | jf_playback_activity a 37 | JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId" 38 | JOIN jf_libraries l ON i."ParentId" = l."Id" 39 | WHERE 40 | a."ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' as INTERVAL) AND NOW() 41 | GROUP BY 42 | l."Name", DATE_TRUNC('day', a."ActivityDateInserted") 43 | ) counts 44 | ON counts."Date" = dates."Date" AND counts."Library" = l."Name" 45 | ORDER BY 46 | "Date", "Library"; 47 | END; 48 | $BODY$; 49 | 50 | ALTER FUNCTION fs_watch_stats_over_time(integer) 51 | OWNER TO "${process.env.POSTGRES_ROLE}"; 52 | `).catch(function(error) { 53 | console.error(error); 54 | }); 55 | }; 56 | 57 | exports.down = async function (knex) { 58 | await knex.raw(` 59 | DROP FUNCTION IF EXISTS fs_watch_stats_over_time(integer); 60 | `); 61 | }; 62 | -------------------------------------------------------------------------------- /backend/migrations/023_fs_watch_stats_popular_hour_of_day_function,js.js: -------------------------------------------------------------------------------- 1 | exports.up =async function(knex) { 2 | return knex.raw(` 3 | CREATE OR REPLACE FUNCTION fs_watch_stats_popular_hour_of_day( 4 | days integer 5 | ) RETURNS TABLE("Hour" integer, "Count" integer, "Library" text) 6 | LANGUAGE 'plpgsql' 7 | COST 100 8 | VOLATILE PARALLEL UNSAFE 9 | ROWS 1000 10 | AS $BODY$ 11 | BEGIN 12 | RETURN QUERY 13 | SELECT 14 | h."Hour", 15 | COUNT(a."Id")::integer AS "Count", 16 | l."Name" AS "Library" 17 | FROM 18 | ( 19 | SELECT 20 | generate_series(0, 23) AS "Hour" 21 | ) h 22 | CROSS JOIN jf_libraries l 23 | LEFT JOIN jf_library_items i ON i."ParentId" = l."Id" 24 | LEFT JOIN ( 25 | SELECT 26 | "NowPlayingItemId", 27 | DATE_PART('hour', "ActivityDateInserted") AS "Hour", 28 | "Id" 29 | FROM 30 | jf_playback_activity 31 | WHERE 32 | "ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' AS INTERVAL) AND NOW() 33 | ) a ON a."NowPlayingItemId" = i."Id" AND a."Hour"::integer = h."Hour" 34 | WHERE 35 | l."Id" IN (SELECT "Id" FROM jf_libraries) 36 | GROUP BY 37 | h."Hour", 38 | l."Name" 39 | ORDER BY 40 | l."Name", 41 | h."Hour"; 42 | END; 43 | $BODY$; 44 | ALTER FUNCTION fs_watch_stats_popular_hour_of_day(integer) 45 | OWNER TO "${process.env.POSTGRES_ROLE}"; 46 | `).catch(function(error) { 47 | console.error(error); 48 | }); 49 | }; 50 | 51 | exports.down = function(knex) { 52 | return knex.raw('DROP FUNCTION IF EXISTS fs_watch_stats_popular_hour_of_day(integer)'); 53 | }; 54 | -------------------------------------------------------------------------------- /backend/migrations/024_jf_item_info_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try { 3 | const tableExists = await knex.schema.hasTable('jf_item_info'); 4 | if (!tableExists) { 5 | await knex.schema.createTable('jf_item_info', function(table) { 6 | table.text('Id').notNullable().primary(); 7 | table.text('Path'); 8 | table.text('Name'); 9 | table.bigInteger('Size'); 10 | table.bigInteger('Bitrate'); 11 | table.json('MediaStreams'); 12 | table.text('Type'); 13 | }); 14 | 15 | await knex.raw(`ALTER TABLE IF EXISTS jf_item_info OWNER TO "${process.env.POSTGRES_ROLE}";`); 16 | } 17 | } catch (error) { 18 | console.error(error); 19 | } 20 | }; 21 | 22 | exports.down = async function(knex) { 23 | try { 24 | await knex.schema.dropTableIfExists('jf_item_info'); 25 | } catch (error) { 26 | console.error(error); 27 | } 28 | }; 29 | 30 | -------------------------------------------------------------------------------- /backend/migrations/027_js_library_metadata_view.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.raw(` 3 | CREATE OR REPLACE VIEW public.js_library_metadata AS 4 | select 5 | l."Id", 6 | l."Name", 7 | sum(ii."Size") "Size", 8 | count(*) files 9 | from jf_libraries l 10 | join jf_library_items i 11 | on i."ParentId"=l."Id" 12 | left join jf_library_episodes e 13 | on e."SeriesId"=i."Id" 14 | left join jf_item_info ii 15 | on (ii."Id"=i."Id" or ii."Id"=e."EpisodeId") 16 | group by l."Id",l."Name" 17 | `).catch(function(error) { 18 | console.error(error); 19 | }); 20 | }; 21 | 22 | exports.down = async function(knex) { 23 | await knex.raw(`DROP VIEW js_library_metadata;`); 24 | }; 25 | -------------------------------------------------------------------------------- /backend/migrations/028_jf_playback_reporting_plugin_data_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try { 3 | const tableExists = await knex.schema.hasTable('jf_playback_reporting_plugin_data'); 4 | if (!tableExists) { 5 | await knex.schema.createTable('jf_playback_reporting_plugin_data', function(table) { 6 | table.bigInteger('rowid').notNullable().primary(); 7 | table.timestamp('DateCreated', { useTz: true }); 8 | table.text('UserId'); 9 | table.text('ItemId'); 10 | table.text('ItemType'); 11 | table.text('ItemName'); 12 | table.text('PlaybackMethod'); 13 | table.text('ClientName'); 14 | table.text('DeviceName'); 15 | table.bigInteger('PlayDuration'); 16 | }); 17 | 18 | await knex.raw(`ALTER TABLE IF EXISTS jf_playback_reporting_plugin_data OWNER TO "${process.env.POSTGRES_ROLE}";`); 19 | } 20 | } catch (error) { 21 | console.error(error); 22 | } 23 | }; 24 | 25 | exports.down = async function(knex) { 26 | try { 27 | await knex.schema.dropTableIfExists('jf_playback_reporting_plugin_data'); 28 | } catch (error) { 29 | console.error(error); 30 | } 31 | }; 32 | 33 | -------------------------------------------------------------------------------- /backend/migrations/030_jf_logging_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try { 3 | const hasTable = await knex.schema.hasTable('jf_logging'); 4 | if (!hasTable) { 5 | await knex.schema.createTable('jf_logging', function(table) { 6 | table.text('Id').primary(); 7 | table.text('Name').notNullable(); 8 | table.text('Type').notNullable(); 9 | table.text('ExecutionType'); 10 | table.text('Duration').notNullable(); 11 | table.timestamp('TimeRun').defaultTo(knex.fn.now()); 12 | table.json('Log'); 13 | table.text('Result'); 14 | }); 15 | await knex.raw(`ALTER TABLE jf_logging OWNER TO "${process.env.POSTGRES_ROLE}";`); 16 | } 17 | } catch (error) { 18 | console.error(error); 19 | } 20 | }; 21 | 22 | exports.down = async function(knex) { 23 | try { 24 | await knex.schema.dropTableIfExists('jf_logging'); 25 | } catch (error) { 26 | console.error(error); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /backend/migrations/031_jd_remove_orphaned_data.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema.raw(` 3 | CREATE OR REPLACE PROCEDURE jd_remove_orphaned_data() AS $$ 4 | BEGIN 5 | DELETE FROM public.jf_library_episodes 6 | WHERE "SeriesId" NOT IN ( 7 | SELECT "Id" 8 | FROM public.jf_library_items 9 | ); 10 | 11 | DELETE FROM public.jf_library_seasons 12 | WHERE "SeriesId" NOT IN ( 13 | SELECT "Id" 14 | FROM public.jf_library_items 15 | ); 16 | 17 | DELETE FROM public.jf_item_info 18 | WHERE "Id" NOT IN ( 19 | SELECT "Id" 20 | FROM public.jf_library_items 21 | ) 22 | AND "Type" = 'Item'; 23 | 24 | DELETE FROM public.jf_item_info 25 | WHERE "Id" NOT IN ( 26 | SELECT "EpisodeId" 27 | FROM public.jf_library_episodes 28 | ) 29 | AND "Type" = 'Episode'; 30 | END; 31 | $$ LANGUAGE plpgsql; 32 | 33 | `).catch(function(error) { 34 | console.error(error); 35 | }); 36 | }; 37 | 38 | exports.down = function(knex) { 39 | return knex.schema.raw(` 40 | DROP PROCEDURE jd_remove_orphaned_data; 41 | `); 42 | }; 43 | -------------------------------------------------------------------------------- /backend/migrations/032_app_config_table_add_auth_flag.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try 3 | { 4 | const hasTable = await knex.schema.hasTable('app_config'); 5 | if (hasTable) { 6 | await knex.schema.alterTable('app_config', function(table) { 7 | table.boolean('REQUIRE_LOGIN').defaultTo(true); 8 | }); 9 | } 10 | }catch (error) { 11 | console.error(error); 12 | } 13 | }; 14 | 15 | exports.down = async function(knex) { 16 | try { 17 | await knex.schema.alterTable('app_config', function(table) { 18 | table.dropColumn('REQUIRE_LOGIN'); 19 | }); 20 | } catch (error) { 21 | console.error(error); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /backend/migrations/034_jf_libraries_table_add_stat_columns.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try 3 | { 4 | const hasTable = await knex.schema.hasTable('jf_libraries'); 5 | if (hasTable) { 6 | await knex.schema.alterTable('jf_libraries', function(table) { 7 | table.bigInteger('total_play_time'); 8 | table.bigInteger('item_count'); 9 | table.bigInteger('season_count'); 10 | table.bigInteger('episode_count'); 11 | }); 12 | } 13 | }catch (error) { 14 | console.error(error); 15 | } 16 | }; 17 | 18 | exports.down = async function(knex) { 19 | try { 20 | await knex.schema.alterTable('jf_libraries', function(table) { 21 | table.dropColumn('total_play_time'); 22 | table.dropColumn('item_count'); 23 | table.dropColumn('season_count'); 24 | table.dropColumn('episode_count'); 25 | }); 26 | } catch (error) { 27 | console.error(error); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /backend/migrations/035_ju_update_library_stats_data.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema.raw(` 3 | CREATE OR REPLACE PROCEDURE ju_update_library_stats_data() 4 | LANGUAGE plpgsql 5 | AS $$ 6 | BEGIN 7 | UPDATE jf_libraries l 8 | SET 9 | total_play_time = ( 10 | SELECT COALESCE(SUM(COALESCE(i_1."RunTimeTicks", e_1."RunTimeTicks")), 0) AS sum 11 | FROM jf_library_items i_1 12 | LEFT JOIN jf_library_episodes e_1 ON i_1."Id" = e_1."SeriesId" 13 | WHERE i_1."ParentId" = l."Id" 14 | AND ( 15 | (i_1."Type" <> 'Series'::text AND e_1."Id" IS NULL) 16 | OR (i_1."Type" = 'Series'::text AND e_1."Id" IS NOT NULL) 17 | ) 18 | ), 19 | item_count = COALESCE(cv."Library_Count", 0::bigint), 20 | season_count = COALESCE(cv."Season_Count", 0::bigint), 21 | episode_count = COALESCE(cv."Episode_Count", 0::bigint) 22 | FROM jf_library_count_view cv 23 | WHERE cv."Id" = l."Id"; 24 | END; 25 | $$; 26 | 27 | `).catch(function(error) { 28 | console.error(error); 29 | }); 30 | }; 31 | 32 | exports.down = function(knex) { 33 | return knex.schema.raw(` 34 | DROP PROCEDURE ju_update_library_stats_data; 35 | `); 36 | }; 37 | -------------------------------------------------------------------------------- /backend/migrations/037_jf_library_items_with_playcount_playtime.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | const query = ` 3 | CREATE OR REPLACE VIEW jf_library_items_with_playcount_playtime AS 4 | SELECT 5 | i."Id", 6 | i."Name", 7 | i."ServerId", 8 | i."PremiereDate", 9 | i."EndDate", 10 | i."CommunityRating", 11 | i."RunTimeTicks", 12 | i."ProductionYear", 13 | i."IsFolder", 14 | i."Type", 15 | i."Status", 16 | i."ImageTagsPrimary", 17 | i."ImageTagsBanner", 18 | i."ImageTagsLogo", 19 | i."ImageTagsThumb", 20 | i."BackdropImageTags", 21 | i."ParentId", 22 | i."PrimaryImageHash", 23 | count(a."NowPlayingItemId") times_played, 24 | coalesce(sum(a."PlaybackDuration"),0) total_play_time 25 | FROM jf_library_items i 26 | left join jf_playback_activity a 27 | on i."Id"=a."NowPlayingItemId" 28 | group by i."Id" 29 | order by times_played desc 30 | `; 31 | 32 | return knex.schema.raw(query).catch(function(error) { 33 | console.error(error); 34 | }); 35 | }; 36 | 37 | 38 | 39 | exports.down = function(knex) { 40 | return knex.schema.raw(`DROP VIEW public.jf_library_items_with_playcount_playtime;`); 41 | }; 42 | -------------------------------------------------------------------------------- /backend/migrations/038_jf_playback_activity_add_stream_data_columns.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try 3 | { 4 | const hasTable = await knex.schema.hasTable('jf_playback_activity'); 5 | if (hasTable) { 6 | await knex.schema.alterTable('jf_playback_activity', function(table) { 7 | table.json('MediaStreams'); 8 | table.json('TranscodingInfo'); 9 | table.json('PlayState'); 10 | table.text('OriginalContainer'); 11 | table.text('RemoteEndPoint'); 12 | }); 13 | } 14 | }catch (error) { 15 | console.error(error); 16 | } 17 | }; 18 | 19 | exports.down = async function(knex) { 20 | try { 21 | await knex.schema.alterTable('jf_playback_activity', function(table) { 22 | table.dropColumn('MediaStreams'); 23 | table.dropColumn('TranscodingInfo'); 24 | table.dropColumn('PlayState'); 25 | table.dropColumn('OriginalContainer'); 26 | table.dropColumn('RemoteEndPoint'); 27 | }); 28 | } catch (error) { 29 | console.error(error); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /backend/migrations/039_jf_activity_watchdog_add_stream_data_columns.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try 3 | { 4 | const hasTable = await knex.schema.hasTable('jf_activity_watchdog'); 5 | if (hasTable) { 6 | await knex.schema.alterTable('jf_activity_watchdog', function(table) { 7 | table.json('MediaStreams'); 8 | table.json('TranscodingInfo'); 9 | table.json('PlayState'); 10 | table.text('OriginalContainer'); 11 | table.text('RemoteEndPoint'); 12 | }); 13 | } 14 | }catch (error) { 15 | console.error(error); 16 | } 17 | }; 18 | 19 | exports.down = async function(knex) { 20 | try { 21 | await knex.schema.alterTable('jf_activity_watchdog', function(table) { 22 | table.dropColumn('MediaStreams'); 23 | table.dropColumn('TranscodingInfo'); 24 | table.dropColumn('PlayState'); 25 | table.dropColumn('OriginalContainer'); 26 | table.dropColumn('RemoteEndPoint'); 27 | }); 28 | } catch (error) { 29 | console.error(error); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /backend/migrations/040_app_config_add_general_settings_column.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try 3 | { 4 | const hasTable = await knex.schema.hasTable('app_config'); 5 | if (hasTable) { 6 | await knex.schema.alterTable('app_config', function(table) { 7 | table.json('settings').defaultTo({time_format:'12hr'}); 8 | }); 9 | } 10 | }catch (error) { 11 | console.error(error); 12 | } 13 | }; 14 | 15 | exports.down = async function(knex) { 16 | try { 17 | await knex.schema.alterTable('app_config', function(table) { 18 | table.dropColumn('settings'); 19 | }); 20 | } catch (error) { 21 | console.error(error); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /backend/migrations/042_app_config_add_api_keys_column.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try 3 | { 4 | const hasTable = await knex.schema.hasTable('app_config'); 5 | if (hasTable) { 6 | await knex.schema.alterTable('app_config', function(table) { 7 | table.json('api_keys'); 8 | }); 9 | } 10 | }catch (error) { 11 | console.error(error); 12 | } 13 | }; 14 | 15 | exports.down = async function(knex) { 16 | try { 17 | await knex.schema.alterTable('app_config', function(table) { 18 | table.dropColumn('api_keys'); 19 | }); 20 | } catch (error) { 21 | console.error(error); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /backend/migrations/043_jf_playback_activity_add_serverid_column.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try 3 | { 4 | const hasTable = await knex.schema.hasTable('jf_playback_activity'); 5 | if (hasTable) { 6 | await knex.schema.alterTable('jf_playback_activity', function(table) { 7 | table.text('ServerId'); 8 | table.boolean('imported').defaultTo(false); 9 | }); 10 | } 11 | }catch (error) { 12 | console.error(error); 13 | } 14 | }; 15 | 16 | exports.down = async function(knex) { 17 | try { 18 | await knex.schema.alterTable('jf_playback_activity', function(table) { 19 | table.dropColumn('ServerId'); 20 | table.dropColumn('imported'); 21 | }); 22 | } catch (error) { 23 | console.error(error); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /backend/migrations/044_jf_activity_watchdog_add_serverid_column.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try 3 | { 4 | const hasTable = await knex.schema.hasTable('jf_activity_watchdog'); 5 | if (hasTable) { 6 | await knex.schema.alterTable('jf_activity_watchdog', function(table) { 7 | table.text('ServerId'); 8 | }); 9 | } 10 | }catch (error) { 11 | console.error(error); 12 | } 13 | }; 14 | 15 | exports.down = async function(knex) { 16 | try { 17 | await knex.schema.alterTable('jf_activity_watchdog', function(table) { 18 | table.dropColumn('ServerId'); 19 | }); 20 | } catch (error) { 21 | console.error(error); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /backend/migrations/045_ji_insert_playback_plugin_data_to_activity_table.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema.raw(` 3 | CREATE OR REPLACE PROCEDURE ji_insert_playback_plugin_data_to_activity_table() AS $$ 4 | BEGIN 5 | insert into jf_playback_activity 6 | SELECT 7 | rowid, 8 | false "IsPaused", 9 | pb."UserId", 10 | u."Name", 11 | pb."ClientName", 12 | pb."DeviceName", 13 | null "DeviceId", 14 | null "ApplicationVersion", 15 | "ItemId" "NowPlayingItemId", 16 | "ItemName" "NowPlayingItemName", 17 | CASE WHEN e."EpisodeId"=pb."ItemId" THEN e."SeasonId" ELSE null END "SeasonId", 18 | CASE WHEN i."Id"=e."SeriesId" THEN i."Name" ELSE null END "SeriesName", 19 | CASE WHEN e."EpisodeId"=pb."ItemId" THEN e."Id" ELSE null END "EpisodeId", 20 | "PlayDuration" "PlaybackDuration", 21 | "DateCreated" "ActivityDateInserted", 22 | "PlaybackMethod" "PlayMethod", 23 | null "MediaStreams", 24 | null "TranscodingInfo", 25 | null "PlayState", 26 | null "OriginalContainer", 27 | null "RemoteEndPoint", 28 | null "ServerId", 29 | true "imported" 30 | FROM public.jf_playback_reporting_plugin_data pb 31 | LEFT JOIN public.jf_users u 32 | on u."Id"=pb."UserId" 33 | 34 | LEFT JOIN public.jf_library_episodes e 35 | on e."EpisodeId"=pb."ItemId" 36 | 37 | LEFT JOIN public.jf_library_items i 38 | on i."Id"=pb."ItemId" 39 | or i."Id"=e."SeriesId" 40 | 41 | WHERE NOT EXISTS 42 | ( 43 | SELECT "Id" "rowid" 44 | FROM jf_playback_activity 45 | WHERE imported=true 46 | ); 47 | END; 48 | $$ LANGUAGE plpgsql; 49 | `).catch(function(error) { 50 | console.error(error); 51 | }); 52 | }; 53 | 54 | exports.down = function(knex) { 55 | return knex.schema.raw(` 56 | DROP PROCEDURE ji_insert_playback_plugin_data_to_activity_table; 57 | `); 58 | }; 59 | -------------------------------------------------------------------------------- /backend/migrations/046_jf_library_items_table_add_archived_column.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try 3 | { 4 | const hasTable = await knex.schema.hasTable('jf_library_items'); 5 | if (hasTable) { 6 | await knex.schema.alterTable('jf_library_items', function(table) { 7 | table.boolean('archived').defaultTo(false); 8 | 9 | }); 10 | } 11 | }catch (error) { 12 | console.error(error); 13 | } 14 | }; 15 | 16 | exports.down = async function(knex) { 17 | try { 18 | await knex.schema.alterTable('jf_library_items', function(table) { 19 | table.dropColumn('archived'); 20 | }); 21 | 22 | } catch (error) { 23 | console.error(error); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /backend/migrations/052_jf_libraries_table_add_archived_column.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try 3 | { 4 | const hasTable = await knex.schema.hasTable('jf_libraries'); 5 | if (hasTable) { 6 | await knex.schema.alterTable('jf_libraries', function(table) { 7 | table.boolean('archived').defaultTo(false); 8 | 9 | }); 10 | 11 | } 12 | }catch (error) { 13 | console.error(error); 14 | } 15 | }; 16 | 17 | exports.down = async function(knex) { 18 | try { 19 | await knex.schema.alterTable('jf_libraries', function(table) { 20 | table.dropColumn('archived'); 21 | }); 22 | 23 | } catch (error) { 24 | console.error(error); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /backend/migrations/054_jf_library_episodes_table_add_archived_column.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try 3 | { 4 | const hasTable = await knex.schema.hasTable('jf_library_episodes'); 5 | if (hasTable) { 6 | await knex.schema.alterTable('jf_library_episodes', function(table) { 7 | table.boolean('archived').defaultTo(false); 8 | 9 | }); 10 | 11 | } 12 | }catch (error) { 13 | console.error(error); 14 | } 15 | }; 16 | 17 | exports.down = async function(knex) { 18 | try { 19 | await knex.schema.alterTable('jf_library_episodes', function(table) { 20 | table.dropColumn('archived'); 21 | }); 22 | 23 | } catch (error) { 24 | console.error(error); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /backend/migrations/055_jf_library_seasons_table_add_archived_column.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try 3 | { 4 | const hasTable = await knex.schema.hasTable('jf_library_seasons'); 5 | if (hasTable) { 6 | await knex.schema.alterTable('jf_library_seasons', function(table) { 7 | table.boolean('archived').defaultTo(false); 8 | 9 | }); 10 | 11 | } 12 | }catch (error) { 13 | console.error(error); 14 | } 15 | }; 16 | 17 | exports.down = async function(knex) { 18 | try { 19 | await knex.schema.alterTable('jf_library_seasons', function(table) { 20 | table.dropColumn('archived'); 21 | }); 22 | 23 | } catch (error) { 24 | console.error(error); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /backend/migrations/056_js_library_metadata.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try 3 | { 4 | await knex.schema.raw(` 5 | CREATE OR REPLACE VIEW public.js_library_metadata 6 | AS 7 | SELECT l."Id", 8 | l."Name", 9 | sum(ii."Size") AS "Size", 10 | count(*) AS files 11 | FROM jf_libraries l 12 | JOIN jf_library_items i ON i."ParentId" = l."Id" AND i.archived=false 13 | LEFT JOIN jf_library_episodes e ON e."SeriesId" = i."Id" AND e.archived=false 14 | LEFT JOIN jf_item_info ii ON ii."Id" = i."Id" OR ii."Id" = e."EpisodeId" 15 | GROUP BY l."Id", l."Name";`); 16 | 17 | }catch (error) { 18 | console.error(error); 19 | } 20 | }; 21 | 22 | exports.down = async function(knex) { 23 | try { 24 | await knex.schema.raw(` 25 | CREATE OR REPLACE VIEW public.js_library_metadata 26 | AS 27 | SELECT l."Id", 28 | l."Name", 29 | sum(ii."Size") AS "Size", 30 | count(*) AS files 31 | FROM jf_libraries l 32 | JOIN jf_library_items i ON i."ParentId" = l."Id" 33 | LEFT JOIN jf_library_episodes e ON e."SeriesId" = i."Id" 34 | LEFT JOIN jf_item_info ii ON ii."Id" = i."Id" OR ii."Id" = e."EpisodeId" 35 | GROUP BY l."Id", l."Name";`); 36 | } catch (error) { 37 | console.error(error); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /backend/migrations/057_jf_library_count_view.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | try 3 | { 4 | await knex.schema.raw(` 5 | CREATE OR REPLACE VIEW public.jf_library_count_view 6 | AS 7 | SELECT l."Id", 8 | l."Name", 9 | l."CollectionType", 10 | count(DISTINCT i."Id") AS "Library_Count", 11 | count(DISTINCT s."Id") AS "Season_Count", 12 | count(DISTINCT e."Id") AS "Episode_Count" 13 | FROM jf_libraries l 14 | JOIN jf_library_items i ON i."ParentId" = l."Id" AND i.archived=false 15 | LEFT JOIN jf_library_seasons s ON s."SeriesId" = i."Id" AND s.archived=false 16 | LEFT JOIN jf_library_episodes e ON e."SeasonId" = s."Id" AND e.archived=false 17 | GROUP BY l."Id", l."Name" 18 | ORDER BY (count(DISTINCT i."Id")) DESC;`); 19 | 20 | }catch (error) { 21 | console.error(error); 22 | } 23 | }; 24 | 25 | exports.down = async function(knex) { 26 | try { 27 | await knex.schema.raw(` 28 | CREATE OR REPLACE VIEW public.jf_library_count_view 29 | AS 30 | SELECT l."Id", 31 | l."Name", 32 | l."CollectionType", 33 | count(DISTINCT i."Id") AS "Library_Count", 34 | count(DISTINCT s."Id") AS "Season_Count", 35 | count(DISTINCT e."Id") AS "Episode_Count" 36 | FROM jf_libraries l 37 | JOIN jf_library_items i ON i."ParentId" = l."Id" 38 | LEFT JOIN jf_library_seasons s ON s."SeriesId" = i."Id" 39 | LEFT JOIN jf_library_episodes e ON e."SeasonId" = s."Id" 40 | GROUP BY l."Id", l."Name" 41 | ORDER BY (count(DISTINCT i."Id")) DESC;`); 42 | } catch (error) { 43 | console.error(error); 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /backend/migrations/064_app_config_remove_default_identity.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | try { 3 | // Drop the primary key constraint temporarily 4 | await knex.schema.raw('ALTER TABLE "app_config" DROP CONSTRAINT app_config_pkey'); 5 | 6 | // Alter the column with the desired modifications 7 | await knex.schema.alterTable("app_config", function (table) { 8 | table.integer("ID").alter().defaultTo(null).notNullable(); 9 | }); 10 | 11 | // Add the primary key constraint back 12 | await knex.schema.raw('ALTER TABLE "app_config" ADD PRIMARY KEY ("ID")'); 13 | } catch (error) { 14 | console.error(error); 15 | } 16 | }; 17 | 18 | exports.down = async function (knex) { 19 | try { 20 | // Drop the primary key constraint temporarily 21 | await knex.schema.raw('ALTER TABLE "app_config" DROP CONSTRAINT app_config_pkey'); 22 | 23 | // Alter the column back to its original state 24 | await knex.schema.alterTable("app_config", function (table) { 25 | table.integer("ID").alter().defaultTo(null).notNullable(); // Modify this line if needed 26 | }); 27 | 28 | // Add the primary key constraint back 29 | await knex.schema.raw('ALTER TABLE "app_config" ADD PRIMARY KEY ("ID")'); 30 | } catch (error) { 31 | console.error(error); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /backend/migrations/066_jf_item_info_add_date_created.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | try { 3 | await knex.schema.alterTable("jf_library_items", function (table) { 4 | table.timestamp("DateCreated"); 5 | }); 6 | await knex.schema.alterTable("jf_library_episodes", function (table) { 7 | table.timestamp("DateCreated"); 8 | }); 9 | } catch (error) { 10 | console.error(error); 11 | } 12 | }; 13 | 14 | exports.down = async function (knex) { 15 | try { 16 | await knex.schema.alterTable("jf_library_items", function (table) { 17 | table.dropColumn("DateCreated"); 18 | }); 19 | await knex.schema.alterTable("jf_library_episodes", function (table) { 20 | table.dropColumn("DateCreated"); 21 | }); 22 | } catch (error) { 23 | console.error(error); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /backend/migrations/070_jf_playback_activity_add_unique_constraint.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | try { 3 | const hasTable = await knex.schema.hasTable("app_config"); 4 | if (hasTable) { 5 | await knex.schema.alterTable("jf_playback_activity", function (table) { 6 | table.unique("Id"); 7 | }); 8 | } 9 | } catch (error) { 10 | console.error(error); 11 | } 12 | }; 13 | 14 | exports.down = async function (knex) { 15 | try { 16 | await knex.schema.alterTable("jf_playback_activity", function (table) { 17 | table.jf_playback_activity("Id"); 18 | }); 19 | } catch (error) { 20 | console.error(error); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /backend/migrations/071_jf_watchdog_table_add_activity_id_field.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | return knex.transaction(async (trx) => { 3 | try { 4 | const hasTable = await trx.schema.hasTable("jf_activity_watchdog"); 5 | if (hasTable) { 6 | await trx("jf_activity_watchdog").truncate(); 7 | await trx.schema.alterTable("jf_activity_watchdog", function (table) { 8 | table.text("ActivityId").notNullable(); 9 | }); 10 | } 11 | } catch (error) { 12 | console.error(error); 13 | throw error; 14 | } 15 | }); 16 | }; 17 | 18 | exports.down = async function (knex) { 19 | return knex.transaction(async (trx) => { 20 | try { 21 | await trx.schema.alterTable("jf_activity_watchdog", function (table) { 22 | table.dropColumn("ActivityId"); 23 | }); 24 | } catch (error) { 25 | console.error(error); 26 | throw error; 27 | } 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /backend/migrations/072_jf_library_episodes_add_primaty_image_hash.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | try { 3 | await knex.schema.alterTable("jf_library_episodes", function (table) { 4 | table.text("PrimaryImageHash"); 5 | }); 6 | } catch (error) { 7 | console.error(error); 8 | } 9 | }; 10 | 11 | exports.down = async function (knex) { 12 | try { 13 | await knex.schema.alterTable("jf_library_episodes", function (table) { 14 | table.dropColumn("PrimaryImageHash"); 15 | }); 16 | } catch (error) { 17 | console.error(error); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /backend/migrations/079_create_view_jf_playback_activity_with_metadata.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | try { 3 | await knex.schema.raw(` 4 | DROP VIEW IF EXISTS public.jf_playback_activity_with_metadata; 5 | 6 | CREATE VIEW jf_playback_activity_with_metadata AS 7 | select a.*,e."IndexNumber" as "EpisodeNumber",e."ParentIndexNumber" as "SeasonNumber",i."ParentId" 8 | FROM "jf_playback_activity" AS "a" 9 | LEFT JOIN jf_library_episodes AS e 10 | ON "a"."EpisodeId" = "e"."EpisodeId" 11 | AND "a"."SeasonId" = "e"."SeasonId" 12 | LEFT JOIN jf_library_items AS i 13 | ON "i"."Id" = "a"."NowPlayingItemId" OR "e"."SeriesId" = "i"."Id" 14 | order by a."ActivityDateInserted" desc; 15 | 16 | 17 | ALTER VIEW public.jf_playback_activity_with_metadata 18 | OWNER TO "${process.env.POSTGRES_ROLE}"; 19 | `); 20 | } catch (error) { 21 | console.error(error); 22 | } 23 | }; 24 | 25 | exports.down = async function (knex) { 26 | try { 27 | await knex.schema.raw(` 28 | DROP VIEW IF EXISTS public.jf_playback_activity_with_metadata;`); 29 | } catch (error) { 30 | console.error(error); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /backend/migrations/080_js_latest_playback_activity.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | try { 3 | await knex.schema.raw(` 4 | DROP MATERIALIZED VIEW IF EXISTS public.js_latest_playback_activity; 5 | 6 | CREATE MATERIALIZED VIEW js_latest_playback_activity AS 7 | WITH latest_activity AS ( 8 | SELECT 9 | "NowPlayingItemId", 10 | "EpisodeId", 11 | "UserId", 12 | MAX("ActivityDateInserted") AS max_date 13 | FROM public.jf_playback_activity 14 | GROUP BY "NowPlayingItemId", "EpisodeId", "UserId" 15 | order by max_date desc 16 | ) 17 | SELECT 18 | a.* 19 | FROM public.jf_playback_activity_with_metadata a 20 | JOIN latest_activity u 21 | ON a."NowPlayingItemId" = u."NowPlayingItemId" 22 | AND COALESCE(a."EpisodeId", '1') = COALESCE(u."EpisodeId", '1') 23 | AND a."UserId" = u."UserId" 24 | AND a."ActivityDateInserted" = u.max_date 25 | order by a."ActivityDateInserted" desc; 26 | 27 | 28 | ALTER MATERIALIZED VIEW public.js_latest_playback_activity 29 | OWNER TO "${process.env.POSTGRES_ROLE}"; 30 | `); 31 | } catch (error) { 32 | console.error(error); 33 | } 34 | }; 35 | 36 | exports.down = async function (knex) { 37 | try { 38 | await knex.schema.raw(` 39 | DROP MATERIALIZED VIEW IF EXISTS public.js_latest_playback_activity;`); 40 | } catch (error) { 41 | console.error(error); 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /backend/migrations/081_create_trigger_refresh_function_js_latest_playback_activity.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | try { 3 | await knex.schema.raw(` 4 | CREATE OR REPLACE FUNCTION refresh_js_latest_playback_activity() 5 | RETURNS TRIGGER AS $$ 6 | BEGIN 7 | REFRESH MATERIALIZED VIEW js_latest_playback_activity; 8 | RETURN NULL; 9 | END; 10 | $$ LANGUAGE plpgsql; 11 | 12 | 13 | ALTER MATERIALIZED VIEW public.js_latest_playback_activity 14 | OWNER TO "${process.env.POSTGRES_ROLE}"; 15 | `); 16 | } catch (error) { 17 | console.error(error); 18 | } 19 | }; 20 | 21 | exports.down = async function (knex) { 22 | try { 23 | await knex.schema.raw(` 24 | DROP FUNCTION IF EXISTS public.refresh_js_latest_playback_activity;`); 25 | } catch (error) { 26 | console.error(error); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /backend/migrations/082_create_trigger_refresh_js_latest_playback_activity.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | try { 3 | await knex.schema.raw(` 4 | DROP TRIGGER IF EXISTS refresh_js_latest_playback_activity_trigger ON public.jf_playback_activity; 5 | 6 | DO $$ 7 | BEGIN 8 | IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'refresh_js_latest_playback_activity') THEN 9 | CREATE TRIGGER refresh_js_latest_playback_activity_trigger 10 | AFTER INSERT OR UPDATE OR DELETE ON public.jf_playback_activity 11 | FOR EACH STATEMENT 12 | EXECUTE FUNCTION refresh_js_latest_playback_activity(); 13 | END IF; 14 | END 15 | $$ LANGUAGE plpgsql; 16 | `); 17 | } catch (error) { 18 | console.error(error); 19 | } 20 | }; 21 | 22 | exports.down = async function (knex) { 23 | try { 24 | await knex.schema.raw(` 25 | DROP TRIGGER IF EXISTS refresh_js_latest_playback_activity_trigger ON public.jf_playback_activity;`); 26 | } catch (error) { 27 | console.error(error); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /backend/migrations/084_create_trigger_refresh_function_js_library_stats_overview.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | try { 3 | await knex.schema.raw(` 4 | CREATE OR REPLACE FUNCTION refresh_js_library_stats_overview() 5 | RETURNS TRIGGER AS $$ 6 | BEGIN 7 | REFRESH MATERIALIZED VIEW js_library_stats_overview; 8 | RETURN NULL; 9 | END; 10 | $$ LANGUAGE plpgsql; 11 | 12 | 13 | ALTER MATERIALIZED VIEW public.js_library_stats_overview 14 | OWNER TO "${process.env.POSTGRES_ROLE}"; 15 | `); 16 | } catch (error) { 17 | console.error(error); 18 | } 19 | }; 20 | 21 | exports.down = async function (knex) { 22 | try { 23 | await knex.schema.raw(` 24 | DROP FUNCTION IF EXISTS public.refresh_js_library_stats_overview;`); 25 | } catch (error) { 26 | console.error(error); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /backend/migrations/085_create_trigger_refresh_js_library_stats_overview.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | try { 3 | await knex.schema.raw(` 4 | DROP TRIGGER IF EXISTS refresh_js_library_stats_overview_trigger ON public.jf_playback_activity; 5 | 6 | DO $$ 7 | BEGIN 8 | IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'refresh_js_library_stats_overview') THEN 9 | CREATE TRIGGER refresh_js_library_stats_overview_trigger 10 | AFTER INSERT OR UPDATE OR DELETE ON public.jf_playback_activity 11 | FOR EACH STATEMENT 12 | EXECUTE FUNCTION refresh_js_library_stats_overview(); 13 | END IF; 14 | END 15 | $$ LANGUAGE plpgsql; 16 | `); 17 | } catch (error) { 18 | console.error(error); 19 | } 20 | }; 21 | 22 | exports.down = async function (knex) { 23 | try { 24 | await knex.schema.raw(` 25 | DROP TRIGGER IF EXISTS refresh_js_library_stats_overview_trigger ON public.jf_playback_activity;`); 26 | } catch (error) { 27 | console.error(error); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /backend/migrations/086_drop_all_refresh_triggers_on_activity_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | try { 3 | await knex.schema.raw(` 4 | DROP TRIGGER IF EXISTS refresh_js_library_stats_overview_trigger ON public.jf_playback_activity; 5 | DROP TRIGGER IF EXISTS refresh_js_latest_playback_activity_trigger ON public.jf_playback_activity; 6 | `); 7 | } catch (error) { 8 | console.error(error); 9 | } 10 | }; 11 | 12 | exports.down = async function (knex) { 13 | try { 14 | await knex.schema.raw(` 15 | DROP TRIGGER IF EXISTS refresh_js_library_stats_overview_trigger ON public.jf_playback_activity; 16 | DROP TRIGGER IF EXISTS refresh_js_latest_playback_activity_trigger ON public.jf_playback_activity; 17 | `); 18 | } catch (error) { 19 | console.error(error); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /backend/migrations/094_jf_library_items_table_add_genres.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | try { 3 | const hasTable = await knex.schema.hasTable("jf_library_items"); 4 | if (hasTable) { 5 | await knex.schema.alterTable("jf_library_items", function (table) { 6 | table.jsonb("Genres").defaultTo(JSON.stringify([])); 7 | }); 8 | } 9 | } catch (error) { 10 | console.error(error); 11 | } 12 | }; 13 | 14 | exports.down = async function (knex) { 15 | try { 16 | const hasTable = await knex.schema.hasTable("jf_library_items"); 17 | if (hasTable) { 18 | await knex.schema.alterTable("jf_library_items", function (table) { 19 | table.dropColumn("Genres"); // Drop the column during rollback 20 | }); 21 | } 22 | } catch (error) { 23 | console.error(error); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /backend/models/jf_item_info.js: -------------------------------------------------------------------------------- 1 | const jf_item_info_columns = ["Id", "Path", "Name", "Size", "Bitrate", "MediaStreams", "Type"]; 2 | 3 | const jf_item_info_mapping = (item, typeOverride) => ({ 4 | Id: item.ItemId || item.EpisodeId || item.Id, 5 | Path: item.Path, 6 | Name: item.Name, 7 | Size: item.Size, 8 | Bitrate: item.Bitrate, 9 | MediaStreams: JSON.stringify(item.MediaStreams), 10 | Type: typeOverride !== undefined ? typeOverride : item.Type, 11 | }); 12 | 13 | module.exports = { 14 | jf_item_info_columns, 15 | jf_item_info_mapping, 16 | }; 17 | -------------------------------------------------------------------------------- /backend/models/jf_libraries.js: -------------------------------------------------------------------------------- 1 | ////////////////////////// pn delete move to playback 2 | const jf_libraries_columns = [ 3 | "Id", 4 | "Name", 5 | "ServerId", 6 | "IsFolder", 7 | "Type", 8 | "CollectionType", 9 | "ImageTagsPrimary", 10 | "archived", 11 | ]; 12 | 13 | const jf_libraries_mapping = (item) => ({ 14 | Id: item.Id, 15 | Name: item.Name, 16 | ServerId: item.ServerId, 17 | IsFolder: item.IsFolder, 18 | Type: item.Type, 19 | CollectionType: item.CollectionType? item.CollectionType : 'mixed', 20 | ImageTagsPrimary: 21 | item.ImageTags && item.ImageTags.Primary ? item.ImageTags.Primary : null, 22 | archived: false, 23 | }); 24 | 25 | module.exports = { 26 | jf_libraries_columns, 27 | jf_libraries_mapping, 28 | }; -------------------------------------------------------------------------------- /backend/models/jf_library_episodes.js: -------------------------------------------------------------------------------- 1 | ////////////////////////// pn delete move to playback 2 | const jf_library_episodes_columns = [ 3 | "Id", 4 | "EpisodeId", 5 | "Name", 6 | "ServerId", 7 | "PremiereDate", 8 | "DateCreated", 9 | "OfficialRating", 10 | "CommunityRating", 11 | "RunTimeTicks", 12 | "ProductionYear", 13 | "IndexNumber", 14 | "ParentIndexNumber", 15 | "Type", 16 | "ParentLogoItemId", 17 | "ParentBackdropItemId", 18 | "ParentBackdropImageTags", 19 | "SeriesId", 20 | "SeasonId", 21 | "SeasonName", 22 | "SeriesName", 23 | "PrimaryImageHash", 24 | "archived", 25 | ]; 26 | 27 | const jf_library_episodes_mapping = (item) => ({ 28 | Id: item.Id + item.SeasonId, 29 | EpisodeId: item.Id, 30 | Name: item.Name, 31 | ServerId: item.ServerId, 32 | PremiereDate: item.PremiereDate, 33 | DateCreated: item.DateCreated, 34 | OfficialRating: item.OfficialRating, 35 | CommunityRating: item.CommunityRating, 36 | RunTimeTicks: item.RunTimeTicks, 37 | ProductionYear: item.ProductionYear, 38 | IndexNumber: item.IndexNumber, 39 | ParentIndexNumber: item.ParentIndexNumber, 40 | Type: item.Type, 41 | ParentLogoItemId: item.ParentLogoItemId, 42 | ParentBackdropItemId: item.ParentBackdropItemId, 43 | ParentBackdropImageTags: 44 | item.ParentBackdropImageTags !== undefined 45 | ? item.ParentBackdropImageTags[0] 46 | : null, 47 | SeriesId: item.SeriesId, 48 | SeasonId: item.SeasonId, 49 | SeasonName: item.SeasonName, 50 | SeriesName: item.SeriesName, 51 | PrimaryImageHash: item.ImageTags && item.ImageTags.Primary && item.ImageBlurHashes && item.ImageBlurHashes.Primary && item.ImageBlurHashes.Primary[item.ImageTags["Primary"]] ? item.ImageBlurHashes.Primary[item.ImageTags["Primary"]] : null, 52 | archived: false, 53 | }); 54 | 55 | module.exports = { 56 | jf_library_episodes_columns, 57 | jf_library_episodes_mapping, 58 | }; -------------------------------------------------------------------------------- /backend/models/jf_library_items.js: -------------------------------------------------------------------------------- 1 | const jf_library_items_columns = [ 2 | "Id", 3 | "Name", 4 | "ServerId", 5 | "PremiereDate", 6 | "DateCreated", 7 | "EndDate", 8 | "CommunityRating", 9 | "RunTimeTicks", 10 | "ProductionYear", 11 | "IsFolder", 12 | "Type", 13 | "Status", 14 | "ImageTagsPrimary", 15 | "ImageTagsBanner", 16 | "ImageTagsLogo", 17 | "ImageTagsThumb", 18 | "BackdropImageTags", 19 | "ParentId", 20 | "PrimaryImageHash", 21 | "archived", 22 | "Genres", 23 | ]; 24 | 25 | const jf_library_items_mapping = (item) => ({ 26 | Id: item.Id, 27 | Name: item.Name, 28 | ServerId: item.ServerId, 29 | PremiereDate: item.PremiereDate, 30 | DateCreated: item.DateCreated, 31 | EndDate: item.EndDate, 32 | CommunityRating: item.CommunityRating, 33 | RunTimeTicks: item.RunTimeTicks, 34 | ProductionYear: item.ProductionYear, 35 | IsFolder: item.IsFolder, 36 | Type: item.Type, 37 | Status: item.Status, 38 | ImageTagsPrimary: item.ImageTags && item.ImageTags.Primary ? item.ImageTags.Primary : null, 39 | ImageTagsBanner: item.ImageTags && item.ImageTags.Banner ? item.ImageTags.Banner : null, 40 | ImageTagsLogo: item.ImageTags && item.ImageTags.Logo ? item.ImageTags.Logo : null, 41 | ImageTagsThumb: item.ImageTags && item.ImageTags.Thumb ? item.ImageTags.Thumb : null, 42 | BackdropImageTags: item.BackdropImageTags[0], 43 | ParentId: item.ParentId, 44 | PrimaryImageHash: 45 | item.ImageTags && 46 | item.ImageTags.Primary && 47 | item.ImageBlurHashes && 48 | item.ImageBlurHashes.Primary && 49 | item.ImageBlurHashes.Primary[item.ImageTags["Primary"]] 50 | ? item.ImageBlurHashes.Primary[item.ImageTags["Primary"]] 51 | : null, 52 | archived: false, 53 | Genres: item.Genres && Array.isArray(item.Genres) ? JSON.stringify(item.Genres.map(titleCase)) : [], 54 | }); 55 | 56 | // Utility function to title-case a string 57 | function titleCase(str) { 58 | return str 59 | .toLowerCase() 60 | .split(" ") 61 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 62 | .join(" "); 63 | } 64 | 65 | module.exports = { 66 | jf_library_items_columns, 67 | jf_library_items_mapping, 68 | }; 69 | -------------------------------------------------------------------------------- /backend/models/jf_library_seasons.js: -------------------------------------------------------------------------------- 1 | ////////////////////////// pn delete move to playback 2 | const jf_library_seasons_columns = [ 3 | "Id", 4 | "Name", 5 | "ServerId", 6 | "IndexNumber", 7 | "Type", 8 | "ParentLogoItemId", 9 | "ParentBackdropItemId", 10 | "ParentBackdropImageTags", 11 | "SeriesName", 12 | "SeriesId", 13 | "SeriesPrimaryImageTag", 14 | "archived", 15 | ]; 16 | 17 | const jf_library_seasons_mapping = (item) => ({ 18 | Id: item.Id, 19 | Name: item.Name, 20 | ServerId: item.ServerId, 21 | IndexNumber: item.IndexNumber, 22 | Type: item.Type, 23 | ParentLogoItemId: item.ParentLogoItemId, 24 | ParentBackdropItemId: item.ParentBackdropItemId, 25 | ParentBackdropImageTags: 26 | item.ParentBackdropImageTags !== undefined 27 | ? item.ParentBackdropImageTags[0] 28 | : null, 29 | SeriesName: item.SeriesName, 30 | SeriesId: item.SeriesId, 31 | SeriesPrimaryImageTag: item.SeriesPrimaryImageTag ? item.SeriesPrimaryImageTag : null, 32 | archived: false, 33 | }); 34 | 35 | module.exports = { 36 | jf_library_seasons_columns, 37 | jf_library_seasons_mapping, 38 | }; -------------------------------------------------------------------------------- /backend/models/jf_logging.js: -------------------------------------------------------------------------------- 1 | const jf_logging_columns = [ 2 | "Id", 3 | "Name", 4 | "Type", 5 | "ExecutionType", 6 | "Duration", 7 | "TimeRun", 8 | "Log", 9 | "Result" 10 | ]; 11 | 12 | const jf_logging_mapping = (item) => ({ 13 | Id: item.Id, 14 | Name: item.Name, 15 | Type: item.Type, 16 | ExecutionType: item.ExecutionType || '', 17 | Duration: item.Duration, 18 | TimeRun: item.TimeRun || '', 19 | Log: item.Log, 20 | Result: item.Result || '', 21 | }); 22 | 23 | module.exports = { 24 | jf_logging_columns, 25 | jf_logging_mapping, 26 | }; -------------------------------------------------------------------------------- /backend/models/jf_playback_activity.js: -------------------------------------------------------------------------------- 1 | const columnsPlayback = [ 2 | "Id", 3 | "IsPaused", 4 | "UserId", 5 | "UserName", 6 | "Client", 7 | "DeviceName", 8 | "DeviceId", 9 | "ApplicationVersion", 10 | "NowPlayingItemId", 11 | "NowPlayingItemName", 12 | "EpisodeId", 13 | "SeasonId", 14 | "SeriesName", 15 | "PlaybackDuration", 16 | "PlayMethod", 17 | "ActivityDateInserted", 18 | { name: "MediaStreams", mod: ":json" }, 19 | { name: "TranscodingInfo", mod: ":json" }, 20 | { name: "PlayState", mod: ":json" }, 21 | "OriginalContainer", 22 | "RemoteEndPoint", 23 | "ServerId", 24 | ]; 25 | 26 | const mappingPlayback = (item) => ({ 27 | Id: item.ActivityId !== undefined ? item.ActivityId : item.Id, 28 | IsPaused: item.PlayState.IsPaused !== undefined ? item.PlayState.IsPaused : item.IsPaused, 29 | UserId: item.UserId, 30 | UserName: item.UserName, 31 | Client: item.Client, 32 | DeviceName: item.DeviceName, 33 | DeviceId: item.DeviceId, 34 | ApplicationVersion: item.ApplicationVersion, 35 | NowPlayingItemId: item.NowPlayingItem.SeriesId !== undefined ? item.NowPlayingItem.SeriesId : item.NowPlayingItem.Id, 36 | NowPlayingItemName: item.NowPlayingItem.Name, 37 | EpisodeId: item.NowPlayingItem.SeriesId !== undefined ? item.NowPlayingItem.Id : null, 38 | SeasonId: item.NowPlayingItem.SeasonId || null, 39 | SeriesName: item.NowPlayingItem.SeriesName || null, 40 | PlaybackDuration: item.PlaybackDuration !== undefined ? item.PlaybackDuration : 0, 41 | PlayMethod: item.PlayState.PlayMethod !== undefined ? item.PlayState.PlayMethod : item.PlayMethod, 42 | ActivityDateInserted: item.ActivityDateInserted !== undefined ? item.ActivityDateInserted : new Date().toISOString(), 43 | MediaStreams: item.MediaStreams ? item.MediaStreams : null, 44 | TranscodingInfo: item.TranscodingInfo ? item.TranscodingInfo : null, 45 | PlayState: item.PlayState ? item.PlayState : null, 46 | OriginalContainer: item.OriginalContainer ? item.OriginalContainer : null, 47 | RemoteEndPoint: item.RemoteEndPoint ? item.RemoteEndPoint : null, 48 | ServerId: item.ServerId ? item.ServerId : null, 49 | }); 50 | 51 | module.exports = { 52 | columnsPlayback, 53 | mappingPlayback, 54 | }; 55 | -------------------------------------------------------------------------------- /backend/models/jf_playback_reporting_plugin_data.js: -------------------------------------------------------------------------------- 1 | ////////////////////////// pn delete move to playback 2 | const columnsPlaybackReporting = [ 3 | "rowid", 4 | "DateCreated", 5 | "UserId", 6 | "ItemId", 7 | "ItemType", 8 | "ItemName", 9 | "PlaybackMethod", 10 | "ClientName", 11 | "DeviceName", 12 | "PlayDuration", 13 | ]; 14 | 15 | 16 | const mappingPlaybackReporting = (item) => ({ 17 | rowid:item[0] , 18 | DateCreated:item[1] , 19 | UserId:item[2] , 20 | ItemId:item[3] , 21 | ItemType:item[4] , 22 | ItemName:item[5] , 23 | PlaybackMethod:item[6] , 24 | ClientName:item[7] , 25 | DeviceName:item[8] , 26 | PlayDuration:item[9] , 27 | }); 28 | 29 | module.exports = { 30 | columnsPlaybackReporting, 31 | mappingPlaybackReporting, 32 | }; -------------------------------------------------------------------------------- /backend/models/jf_users.js: -------------------------------------------------------------------------------- 1 | ////////////////////////// pn delete move to playback 2 | const jf_users_columns = [ 3 | "Id", 4 | "Name", 5 | "PrimaryImageTag", 6 | "LastLoginDate", 7 | "LastActivityDate", 8 | "IsAdministrator" 9 | ]; 10 | 11 | const jf_users_mapping = (item) => ({ 12 | Id: item.Id, 13 | Name: item.Name, 14 | PrimaryImageTag: item.PrimaryImageTag, 15 | LastLoginDate: item.LastLoginDate, 16 | LastActivityDate: item.LastActivityDate, 17 | IsAdministrator: item.Policy && item.Policy.IsAdministrator ? item.Policy.IsAdministrator : false, 18 | }); 19 | 20 | module.exports = { 21 | jf_users_columns, 22 | jf_users_mapping, 23 | }; -------------------------------------------------------------------------------- /backend/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["backend/backup-data", "*.json"] 3 | } 4 | -------------------------------------------------------------------------------- /backend/routes/logging.js: -------------------------------------------------------------------------------- 1 | const db = require("../db"); 2 | 3 | const express = require("express"); 4 | const router = express.Router(); 5 | // #swagger.tags = ['Logs'] 6 | router.get("/getLogs", async (req, res) => { 7 | try { 8 | const { rows } = await db.query(`SELECT * FROM jf_logging order by "TimeRun" desc LIMIT 50 `); 9 | res.send(rows); 10 | } catch (error) { 11 | res.send(error); 12 | } 13 | }); 14 | 15 | // Handle other routes 16 | router.use((req, res) => { 17 | res.status(404).send({ error: "Not Found" }); 18 | }); 19 | 20 | module.exports = router; 21 | -------------------------------------------------------------------------------- /backend/routes/utils.js: -------------------------------------------------------------------------------- 1 | const { axios } = require("../classes/axios"); 2 | const express = require("express"); 3 | 4 | const router = express.Router(); 5 | 6 | const geoliteUrlBase = "https://geolite.info/geoip/v2.1/city"; 7 | 8 | const geoliteAccountId = process.env.JS_GEOLITE_ACCOUNT_ID; 9 | const geoliteLicenseKey = process.env.JS_GEOLITE_LICENSE_KEY; 10 | 11 | //https://stackoverflow.com/a/29268025 12 | const ipRegex = new RegExp( 13 | /\b(?!(10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168))(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/ 14 | ); 15 | 16 | router.post("/geolocateIp", async (req, res) => { 17 | try { 18 | if (!(geoliteAccountId && geoliteLicenseKey)) { 19 | return res.status(501).send("GeoLite information missing!"); 20 | } 21 | 22 | const { ipAddress } = req.body; 23 | ipRegex.lastIndex = 0; 24 | 25 | if (!ipAddress || !ipRegex.test(ipAddress)) { 26 | return res.status(400).send("Invalid IP address sent!"); 27 | } 28 | 29 | const response = await axios.get(`${geoliteUrlBase}/${ipAddress}`, { 30 | auth: { 31 | username: geoliteAccountId, 32 | password: geoliteLicenseKey, 33 | }, 34 | }); 35 | return res.send(response.data); 36 | } catch (error) { 37 | res.status(503); 38 | res.send(error); 39 | } 40 | }); 41 | 42 | // Handle other routes 43 | router.use((req, res) => { 44 | res.status(404).send({ error: "Not Found" }); 45 | }); 46 | 47 | module.exports = router; 48 | -------------------------------------------------------------------------------- /backend/socket-io-client.js: -------------------------------------------------------------------------------- 1 | const io = require("socket.io-client"); 2 | 3 | class SocketIoClient { 4 | constructor(serverUrl) { 5 | this.serverUrl = serverUrl; 6 | this.client = null; 7 | } 8 | 9 | connect() { 10 | this.client = io(this.serverUrl); 11 | } 12 | 13 | waitForConnection() { 14 | return new Promise((resolve) => { 15 | if (this.client && this.client.connected) { 16 | resolve(); 17 | } else { 18 | this.client.on("connect", resolve); 19 | } 20 | }); 21 | } 22 | 23 | sendMessage(message) { 24 | if (this.client && this.client.connected) { 25 | this.client.emit("message", JSON.stringify(message)); 26 | } 27 | } 28 | } 29 | 30 | module.exports = SocketIoClient; 31 | -------------------------------------------------------------------------------- /backend/tasks/BackupTask.js: -------------------------------------------------------------------------------- 1 | const { parentPort } = require("worker_threads"); 2 | const Logging = require("../classes/logging"); 3 | const backup = require("../classes/backup"); 4 | const { randomUUID } = require("crypto"); 5 | const taskstate = require("../logging/taskstate"); 6 | const taskName = require("../logging/taskName"); 7 | const triggertype = require("../logging/triggertype"); 8 | const { sendUpdate } = require("../ws"); 9 | 10 | async function runBackupTask(triggerType = triggertype.Automatic) { 11 | try { 12 | console.log = (...args) => { 13 | const formattedArgs = args.map((arg) => { 14 | if (typeof arg === "object" && arg !== null) { 15 | try { 16 | return JSON.stringify(arg, null, 2); 17 | } catch (e) { 18 | return "[Circular]"; 19 | } 20 | } 21 | return arg; 22 | }); 23 | parentPort.postMessage({ type: "log", message: formattedArgs.join(" ") }); 24 | }; 25 | const uuid = randomUUID(); 26 | const refLog = { logData: [], uuid: uuid }; 27 | 28 | console.log("Running Scheduled Backup"); 29 | 30 | Logging.insertLog(uuid, triggerType, taskName.backup); 31 | 32 | await backup(refLog); 33 | Logging.updateLog(uuid, refLog.logData, taskstate.SUCCESS); 34 | sendUpdate("BackupTask", { type: "Success", message: `${triggerType} Backup Completed` }); 35 | console.log("Scheduled Backup Complete"); 36 | parentPort.postMessage({ status: "complete" }); 37 | } catch (error) { 38 | parentPort.postMessage({ status: "error", message: error.message }); 39 | 40 | console.log(error); 41 | return []; 42 | } 43 | } 44 | 45 | parentPort.on("message", async (message) => { 46 | if (message.command === "start") { 47 | await runBackupTask(message.triggertype); 48 | process.exit(0); // Exit the worker after the task is done 49 | } 50 | }); 51 | -------------------------------------------------------------------------------- /backend/tasks/FullSyncTask.js: -------------------------------------------------------------------------------- 1 | const { parentPort } = require("worker_threads"); 2 | const triggertype = require("../logging/triggertype"); 3 | const sync = require("../routes/sync"); 4 | 5 | async function runFullSyncTask(triggerType = triggertype.Automatic) { 6 | try { 7 | console.log = (...args) => { 8 | const formattedArgs = args.map((arg) => { 9 | if (typeof arg === "object" && arg !== null) { 10 | try { 11 | return JSON.stringify(arg, null, 2); 12 | } catch (e) { 13 | return "[Circular]"; 14 | } 15 | } 16 | return arg; 17 | }); 18 | parentPort.postMessage({ type: "log", message: formattedArgs.join(" ") }); 19 | }; 20 | await sync.fullSync(triggerType); 21 | 22 | parentPort.postMessage({ status: "complete" }); 23 | } catch (error) { 24 | parentPort.postMessage({ status: "error", message: error.message }); 25 | 26 | console.log(error); 27 | return []; 28 | } 29 | } 30 | 31 | parentPort.on("message", async (message) => { 32 | if (message.command === "start") { 33 | await runFullSyncTask(message.triggertype); 34 | process.exit(0); // Exit the worker after the task is done 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /backend/tasks/PlaybackReportingPluginSyncTask.js: -------------------------------------------------------------------------------- 1 | const { parentPort } = require("worker_threads"); 2 | const sync = require("../routes/sync"); 3 | 4 | async function runPlaybackReportingPluginSyncTask() { 5 | try { 6 | console.log = (...args) => { 7 | const formattedArgs = args.map((arg) => { 8 | if (typeof arg === "object" && arg !== null) { 9 | try { 10 | return JSON.stringify(arg, null, 2); 11 | } catch (e) { 12 | return "[Circular]"; 13 | } 14 | } 15 | return arg; 16 | }); 17 | parentPort.postMessage({ type: "log", message: formattedArgs.join(" ") }); 18 | }; 19 | await sync.syncPlaybackPluginData(); 20 | 21 | parentPort.postMessage({ status: "complete" }); 22 | } catch (error) { 23 | parentPort.postMessage({ status: "error", message: error.message }); 24 | 25 | console.log(error); 26 | return []; 27 | } 28 | } 29 | 30 | parentPort.on("message", async (message) => { 31 | if (message.command === "start") { 32 | await runPlaybackReportingPluginSyncTask(); 33 | process.exit(0); // Exit the worker after the task is done 34 | } 35 | }); 36 | -------------------------------------------------------------------------------- /backend/tasks/RecentlyAddedItemsSyncTask.js: -------------------------------------------------------------------------------- 1 | const { parentPort } = require("worker_threads"); 2 | const triggertype = require("../logging/triggertype"); 3 | const sync = require("../routes/sync"); 4 | 5 | async function runPartialSyncTask(triggerType = triggertype.Automatic) { 6 | try { 7 | console.log = (...args) => { 8 | const formattedArgs = args.map((arg) => { 9 | if (typeof arg === "object" && arg !== null) { 10 | try { 11 | return JSON.stringify(arg, null, 2); 12 | } catch (e) { 13 | return "[Circular]"; 14 | } 15 | } 16 | return arg; 17 | }); 18 | parentPort.postMessage({ type: "log", message: formattedArgs.join(" ") }); 19 | }; 20 | await sync.partialSync(triggerType); 21 | 22 | parentPort.postMessage({ status: "complete" }); 23 | } catch (error) { 24 | parentPort.postMessage({ status: "error", message: error.message }); 25 | 26 | console.log(error); 27 | return []; 28 | } 29 | } 30 | 31 | parentPort.on("message", async (message) => { 32 | if (message.command === "start") { 33 | await runPartialSyncTask(message.triggertype); 34 | process.exit(0); // Exit the worker after the task is done 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /backend/utils/sanitizer.js: -------------------------------------------------------------------------------- 1 | const sanitizeFilename = require("sanitize-filename"); 2 | 3 | const sanitizeFile = (filename) => { 4 | return sanitizeFilename(filename); 5 | }; 6 | 7 | module.exports = sanitizeFile; 8 | -------------------------------------------------------------------------------- /backend/ws-server-singleton.js: -------------------------------------------------------------------------------- 1 | class WebSocketServerSingleton { 2 | constructor() { 3 | if (!WebSocketServerSingleton.instance) { 4 | WebSocketServerSingleton.instance = null; 5 | } 6 | } 7 | 8 | setInstance(io) { 9 | WebSocketServerSingleton.instance = io; 10 | } 11 | 12 | getInstance() { 13 | return WebSocketServerSingleton.instance; 14 | } 15 | } 16 | 17 | module.exports = new WebSocketServerSingleton(); 18 | -------------------------------------------------------------------------------- /backend/ws.js: -------------------------------------------------------------------------------- 1 | // ws.js 2 | const socketIO = require("socket.io"); 3 | const webSocketServerSingleton = require("./ws-server-singleton.js"); 4 | const SocketIoClient = require("./socket-io-client.js"); 5 | 6 | const socketClient = new SocketIoClient("http://127.0.0.1:3000"); 7 | let io; // Store the socket.io server instance 8 | 9 | const setupWebSocketServer = (server, namespacePath) => { 10 | io = socketIO(server, { path: namespacePath + "/socket.io" }); 11 | 12 | socketClient.connect(); 13 | 14 | io.on("connection", (socket) => { 15 | // console.log("Client connected to namespace:", namespacePath); 16 | 17 | socket.on("message", (message) => { 18 | try { 19 | const payload = JSON.parse(message); 20 | if (typeof payload === "object" && payload !== null) { 21 | if (payload.tag && payload.message) { 22 | sendUpdate(payload.tag, payload.message); 23 | } 24 | } 25 | } catch (error) {} 26 | }); 27 | }); 28 | 29 | webSocketServerSingleton.setInstance(io); 30 | }; 31 | 32 | const sendToAllClients = (message) => { 33 | const ioInstance = webSocketServerSingleton.getInstance(); 34 | if (ioInstance) { 35 | ioInstance.emit("message", message); 36 | } 37 | }; 38 | 39 | const sendUpdate = async (tag, message) => { 40 | const ioInstance = webSocketServerSingleton.getInstance(); 41 | if (ioInstance) { 42 | ioInstance.emit(tag, message); 43 | } else { 44 | if (socketClient.client == null || socketClient.client.connected == false) { 45 | socketClient.connect(); 46 | await socketClient.waitForConnection(); 47 | } 48 | 49 | socketClient.sendMessage({ tag: tag, message: message }); 50 | } 51 | }; 52 | 53 | module.exports = { setupWebSocketServer, sendToAllClients, sendUpdate }; 54 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | docker build -t jellystat . -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | jellystat-db: 5 | image: postgres:15.2 6 | shm_size: '1gb' 7 | container_name: jellystat-db 8 | restart: unless-stopped 9 | logging: 10 | driver: "json-file" 11 | options: 12 | max-file: "5" 13 | max-size: "10m" 14 | environment: 15 | POSTGRES_USER: postgres 16 | POSTGRES_PASSWORD: mypassword 17 | volumes: 18 | - postgres-data:/var/lib/postgresql/data 19 | 20 | jellystat: 21 | image: cyfershepard/jellystat:latest 22 | container_name: jellystat 23 | restart: unless-stopped 24 | logging: 25 | driver: "json-file" 26 | options: 27 | max-file: "5" 28 | max-size: "10m" 29 | environment: 30 | POSTGRES_USER: postgres 31 | POSTGRES_PASSWORD: mypassword 32 | POSTGRES_IP: jellystat-db 33 | POSTGRES_PORT: 5432 34 | JWT_SECRET: "my-secret-jwt-key" 35 | TZ: mytimezone # timezone (ex: Europe/Paris) 36 | volumes: 37 | - jellystat-backup-data:/app/backend/backup-data 38 | ports: 39 | - "3000:3000" 40 | depends_on: 41 | - jellystat-db 42 | 43 | networks: 44 | default: 45 | 46 | volumes: 47 | postgres-data: 48 | jellystat-backup-data: 49 | -------------------------------------------------------------------------------- /entry.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | load_secrets() { 4 | # Treat all env vars that start with the prefix 'FILE__' as secrets, 5 | # loading their contents into a variable without the prefix. 6 | 7 | # Loop through all env vars starting with 'FILE__' 8 | for var in $(env | grep '^FILE__'); do 9 | var_name=$(echo "${var}" | cut -d= -f1) 10 | var_value=$(echo "${var}" | cut -d= -f2) 11 | 12 | # Ensure var value is a file 13 | if [ -f "${var_value}" ]; then 14 | 15 | # Strip 'FILE__' prefix to obtain corresponding variable name 16 | new_var_name="${var_name#FILE__}" 17 | 18 | # Notify user if original variable is being overwritten. 19 | if [ -n "$(eval echo \$$new_var_name)" ]; then 20 | echo "Warning: ${new_var_name} was already set but is being overwritten by $var_name" 21 | fi 22 | # Set the new variable with the secret value 23 | export "${new_var_name}=$(cat "${var_value}")" 24 | else 25 | echo "Error: Secret file '${var_value}' does not exist" 26 | exit 1 27 | fi 28 | done 29 | } 30 | 31 | # Load secrets 32 | load_secrets 33 | # Launch Jellystat 34 | npm run start 35 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 25 | JellyStat 26 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/public/favicon.ico -------------------------------------------------------------------------------- /public/icon-b-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/public/icon-b-192.png -------------------------------------------------------------------------------- /public/icon-b-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/public/icon-b-512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "JellyStat", 3 | "name": "Statistics for Jellyfin", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "icon-b-192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "icon-b-512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | PREVIOUS_VERSION=$(git describe --abbrev=0 --tags) 4 | 5 | echo "Choose the version component to increment:" 6 | echo "1. Major" 7 | echo "2. Minor" 8 | echo "3. Patch" 9 | 10 | read -p "Enter your choice: " choice 11 | 12 | case $choice in 13 | 1) 14 | # Increment the major version 15 | MAJOR=$(echo "$PREVIOUS_VERSION" | cut -d. -f1) 16 | MAJOR=$((MAJOR + 1)) 17 | NEW_VERSION="$MAJOR.0.0" 18 | ;; 19 | 2) 20 | # Increment the minor version 21 | MAJOR=$(echo "$PREVIOUS_VERSION" | cut -d. -f1) 22 | MINOR=$(echo "$PREVIOUS_VERSION" | cut -d. -f2) 23 | MINOR=$((MINOR + 1)) 24 | NEW_VERSION="$MAJOR.$MINOR.0" 25 | ;; 26 | 3) 27 | # Increment the patch version 28 | MAJOR=$(echo "$PREVIOUS_VERSION" | cut -d. -f1) 29 | MINOR=$(echo "$PREVIOUS_VERSION" | cut -d. -f2) 30 | PATCH=$(echo "$PREVIOUS_VERSION" | cut -d. -f3) 31 | PATCH=$((PATCH + 1)) 32 | NEW_VERSION="$MAJOR.$MINOR.$PATCH" 33 | ;; 34 | *) 35 | echo "Invalid choice. Exiting." 36 | exit 1 37 | ;; 38 | esac 39 | 40 | # Tag message 41 | TAG_MESSAGE="Release version $NEW_VERSION" 42 | 43 | # Create a new tag 44 | git tag -a "$NEW_VERSION" -m "$TAG_MESSAGE" 45 | 46 | # Push the tag to the remote repository 47 | git push origin "$NEW_VERSION" 48 | 49 | echo "Tag $NEW_VERSION has been created and pushed to the remote repository." 50 | -------------------------------------------------------------------------------- /screenshots/Activity.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/screenshots/Activity.PNG -------------------------------------------------------------------------------- /screenshots/Home.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/screenshots/Home.PNG -------------------------------------------------------------------------------- /screenshots/Libraries.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/screenshots/Libraries.PNG -------------------------------------------------------------------------------- /screenshots/Users.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/screenshots/Users.PNG -------------------------------------------------------------------------------- /screenshots/settings.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/screenshots/settings.PNG -------------------------------------------------------------------------------- /screenshots/stats.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/screenshots/stats.PNG -------------------------------------------------------------------------------- /src/App.test.jsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | 4 | test('renders learn react link', () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Railway'; 3 | src: url('/src/pages/fonts/Raleway-VariableFont_wght.ttf') format('truetype'); 4 | font-weight: normal; 5 | font-style: normal; 6 | } 7 | body { 8 | margin: 0; 9 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 10 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 11 | sans-serif; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | color: white; 15 | } 16 | 17 | * { 18 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 19 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 20 | sans-serif !important; 21 | } 22 | 23 | code { 24 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 25 | monospace; 26 | } 27 | 28 | body { 29 | overflow: auto; /* show scrollbar when needed */ 30 | } 31 | 32 | body::-webkit-scrollbar { 33 | width: 10px; /* set scrollbar width */ 34 | } 35 | 36 | body::-webkit-scrollbar-track { 37 | background-color: transparent; /* set track color */ 38 | } 39 | 40 | body::-webkit-scrollbar-thumb { 41 | background-color: #8888884d; /* set thumb color */ 42 | border-radius: 5px; /* round corners */ 43 | } 44 | 45 | body::-webkit-scrollbar-thumb:hover { 46 | background-color: #88888883; /* set thumb color */ 47 | } 48 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { BrowserRouter } from "react-router-dom"; 4 | 5 | import App from "./App.jsx"; 6 | 7 | import "./index.css"; 8 | import "bootstrap/dist/css/bootstrap.min.css"; 9 | 10 | import i18n from "i18next"; 11 | import Backend from "i18next-http-backend"; 12 | import LanguageDetector from "i18next-browser-languagedetector"; 13 | import { initReactI18next } from "react-i18next"; 14 | 15 | import Loading from "./pages/components/general/loading.jsx"; 16 | import baseUrl from "./lib/baseurl.jsx"; 17 | 18 | i18n 19 | .use(Backend) 20 | .use(LanguageDetector) 21 | .use(initReactI18next) 22 | .init({ 23 | fallbackLng: "en-UK", 24 | debug: false, 25 | backend: { 26 | loadPath: `${baseUrl}/locales/{{lng}}/{{ns}}.json`, 27 | }, 28 | detection: { 29 | order: ["cookie", "localStorage", "sessionStorage", "navigator", "htmlTag", "querystring", "path", "subdomain"], 30 | cache: ["cookie"], 31 | }, 32 | interpolation: { 33 | escapeValue: false, 34 | }, 35 | }) 36 | .then(() => { 37 | ReactDOM.createRoot(document.getElementById("root")).render( 38 | 39 | } /> 40 | 41 | 42 | 43 | 44 | ); 45 | }); 46 | -------------------------------------------------------------------------------- /src/lib/axios_instance.jsx: -------------------------------------------------------------------------------- 1 | import Axios from "axios"; 2 | import baseUrl from "./baseurl"; 3 | 4 | const axios = Axios.create({ baseURL: baseUrl }); 5 | 6 | export default axios; 7 | -------------------------------------------------------------------------------- /src/lib/baseurl.jsx: -------------------------------------------------------------------------------- 1 | const ensureSlashes = (url) => { 2 | if (!url.startsWith("/")) { 3 | url = "/" + url; 4 | } 5 | if (url.endsWith("/")) { 6 | url = url.slice(0, -1); 7 | } 8 | return url; 9 | }; 10 | const baseUrl = window.env?.JS_BASE_URL ? ensureSlashes(window.env?.JS_BASE_URL) : ""; 11 | export default baseUrl; 12 | -------------------------------------------------------------------------------- /src/lib/config.jsx: -------------------------------------------------------------------------------- 1 | import axios from "../lib/axios_instance"; 2 | 3 | class Config { 4 | async fetchConfig() { 5 | const token = localStorage.getItem("token"); 6 | try { 7 | const response = await axios.get("/api/getconfig", { 8 | headers: { 9 | Authorization: `Bearer ${token}`, 10 | }, 11 | }); 12 | const { JF_HOST, APP_USER, REQUIRE_LOGIN, settings, IS_JELLYFIN } = response.data; 13 | return { 14 | hostUrl: JF_HOST, 15 | username: APP_USER, 16 | token: token, 17 | requireLogin: REQUIRE_LOGIN, 18 | settings: settings, 19 | IS_JELLYFIN: IS_JELLYFIN, 20 | }; 21 | } catch (error) { 22 | // console.log(error); 23 | return error; 24 | } 25 | } 26 | 27 | async setConfig(config) { 28 | if (config == undefined) { 29 | config = await this.fetchConfig(); 30 | } 31 | 32 | localStorage.setItem("config", JSON.stringify(config)); 33 | return config; 34 | } 35 | 36 | async getConfig(refreshConfig) { 37 | let config = localStorage.getItem("config"); 38 | if (config != undefined && !refreshConfig) { 39 | return JSON.parse(config); 40 | } else { 41 | return await this.setConfig(); 42 | } 43 | } 44 | } 45 | 46 | export default new Config(); 47 | -------------------------------------------------------------------------------- /src/lib/devices.jsx: -------------------------------------------------------------------------------- 1 | 2 | export const clientData = ["android","ios","safari","chrome","firefox","edge","opera"] 3 | 4 | -------------------------------------------------------------------------------- /src/lib/languages.jsx: -------------------------------------------------------------------------------- 1 | export const languages = [ 2 | { 3 | id: "en-UK", 4 | description: "English", 5 | }, 6 | { 7 | id: "en-US", 8 | description: "English US", 9 | }, 10 | { 11 | id: "fr-FR", 12 | description: "Français", 13 | }, 14 | { 15 | id: "zh-CN", 16 | description: "简体中文", 17 | }, 18 | { 19 | id: "it-IT", 20 | description: "Italiano", 21 | }, 22 | { 23 | id: "ca-ES", 24 | description: "Català", 25 | }, 26 | ]; 27 | -------------------------------------------------------------------------------- /src/lib/navdata.jsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | import HomeFillIcon from 'remixicon-react/HomeFillIcon'; 4 | import BarChartFillIcon from 'remixicon-react/BarChartFillIcon'; 5 | import HistoryFillIcon from 'remixicon-react/HistoryFillIcon'; 6 | import SettingsFillIcon from 'remixicon-react/SettingsFillIcon'; 7 | import GalleryFillIcon from 'remixicon-react/GalleryFillIcon'; 8 | import UserFillIcon from 'remixicon-react/UserFillIcon'; 9 | import InformationFillIcon from 'remixicon-react/InformationFillIcon'; 10 | import { Trans } from 'react-i18next'; 11 | 12 | 13 | export const navData = [ 14 | { 15 | id: 0, 16 | icon: , 17 | text: , 18 | link: "" 19 | }, 20 | { 21 | id: 1, 22 | icon: , 23 | text: , 24 | link: "libraries" 25 | }, 26 | { 27 | id: 2, 28 | icon: , 29 | text: , 30 | link: "users" 31 | }, 32 | { 33 | id: 4, 34 | icon: , 35 | text: , 36 | link: "activity" 37 | }, 38 | { 39 | id: 5, 40 | icon: , 41 | text: , 42 | link: "statistics" 43 | }, 44 | 45 | { 46 | id: 6, 47 | icon: , 48 | text: , 49 | link: "settings" 50 | } 51 | , 52 | 53 | { 54 | id: 7, 55 | icon: , 56 | text: , 57 | link: "about" 58 | } 59 | 60 | ] 61 | 62 | -------------------------------------------------------------------------------- /src/lib/tasklist.jsx: -------------------------------------------------------------------------------- 1 | import { Trans } from "react-i18next"; 2 | 3 | export const taskList = [ 4 | { 5 | id: 0, 6 | name: "PartialJellyfinSync", 7 | description: , 8 | type: "JOB", 9 | link: "/sync/beginPartialSync", 10 | }, 11 | { 12 | id: 1, 13 | name: "JellyfinSync", 14 | description: , 15 | type: "JOB", 16 | link: "/sync/beginSync", 17 | }, 18 | { 19 | id: 2, 20 | name: "JellyfinPlaybackReportingPluginSync", 21 | description: , 22 | type: "IMPORT", 23 | link: "/sync/syncPlaybackPluginData", 24 | }, 25 | { 26 | id: 3, 27 | name: "Backup", 28 | description: , 29 | type: "JOB", 30 | link: "/backup/beginBackup", 31 | }, 32 | ]; 33 | -------------------------------------------------------------------------------- /src/pages/components/general/ComponentLoading.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "../../css/loading.css"; 3 | 4 | function ComponentLoading() { 5 | return ( 6 |
7 |
8 |
9 | ); 10 | } 11 | 12 | export default ComponentLoading; -------------------------------------------------------------------------------- /src/pages/components/general/ErrorBoundary.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | export default class ErrorBoundary extends React.Component { 3 | constructor(props) { 4 | super(props); 5 | this.state = { hasError: false }; 6 | } 7 | 8 | static getDerivedStateFromError(error) { 9 | // Update state to indicate an error has occurred 10 | return { hasError: true }; 11 | } 12 | 13 | componentDidCatch(error, errorInfo) { 14 | const excludedErrors = ["blurhash", "canvas"]; 15 | if (!excludedErrors.some((error) => errorInfo.componentStack.includes(error))) { 16 | console.error(error, errorInfo); 17 | } 18 | } 19 | 20 | render() { 21 | if (this.state.hasError) { 22 | // Render an error message or fallback UI 23 | return <>; 24 | } 25 | 26 | // Render the child components as normal 27 | return this.props.children; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/pages/components/general/busyLoader.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "../../css/loading.css"; 3 | 4 | function BusyLoader() { 5 | return ( 6 |
7 |
8 |
9 | ); 10 | } 11 | 12 | export default BusyLoader; 13 | -------------------------------------------------------------------------------- /src/pages/components/general/error.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "../../css/error.css"; 3 | 4 | function ErrorPage(props) { 5 | return ( 6 |
7 |
{props.message}
8 |
9 | ); 10 | } 11 | 12 | export default ErrorPage; -------------------------------------------------------------------------------- /src/pages/components/general/loading.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "../../css/loading.css"; 3 | 4 | function Loading() { 5 | return ( 6 |
7 |
8 |
9 | ); 10 | } 11 | 12 | export default Loading; -------------------------------------------------------------------------------- /src/pages/components/general/version-card.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import axios from "../../../lib/axios_instance"; 3 | import Row from 'react-bootstrap/Row'; 4 | import Col from 'react-bootstrap/Col'; 5 | 6 | import "../../css/settings/version.css"; 7 | import { Card } from "react-bootstrap"; 8 | import { Trans } from "react-i18next"; 9 | 10 | export default function VersionCard() { 11 | 12 | const token = localStorage.getItem('token'); 13 | const [data, setData] = useState(); 14 | useEffect(() => { 15 | 16 | const fetchVersion = () => { 17 | if (token) { 18 | const url = `/api/CheckForUpdates`; 19 | 20 | axios 21 | .get(url, { 22 | headers: { 23 | Authorization: `Bearer ${token}`, 24 | "Content-Type": "application/json", 25 | }, 26 | }) 27 | .then((data) => { 28 | setData(data.data); 29 | }) 30 | .catch((error) => { 31 | console.log(error); 32 | }); 33 | } 34 | }; 35 | if(!data) 36 | { 37 | fetchVersion(); 38 | } 39 | 40 | const intervalId = setInterval(fetchVersion, 60000 * 5); 41 | return () => clearInterval(intervalId); 42 | }, [data,token]); 43 | 44 | 45 | if(!data) 46 | { 47 | return <>; 48 | } 49 | 50 | 51 | return ( 52 | 53 | 54 | 55 | {data.current_version} 56 | 57 | 58 | 59 | {data.update_available? 60 | 61 | : {data.latest_version} 62 | 63 | : 64 | <> 65 | } 66 | 67 | 68 | 69 | ); 70 | 71 | 72 | } 73 | -------------------------------------------------------------------------------- /src/pages/components/item-info/item-not-found.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import axios from "../../../lib/axios_instance"; 3 | import "../../css/error.css"; 4 | import { Button } from "react-bootstrap"; 5 | import Loading from "../general/loading"; 6 | import { Trans } from "react-i18next"; 7 | 8 | function ItemNotFound(props) { 9 | const [itemId] = useState(props.itemId); 10 | const [loading, setLoading] = useState(false); 11 | const [resultMessage, setResultMessage] = useState(); 12 | const token = localStorage.getItem("token"); 13 | 14 | async function fetchItem() { 15 | setResultMessage(); 16 | setLoading(true); 17 | const result = await axios 18 | .post( 19 | "/sync/fetchItem", 20 | { 21 | itemId: itemId, 22 | }, 23 | { 24 | headers: { 25 | Authorization: `Bearer ${token}`, 26 | "Content-Type": "application/json", 27 | }, 28 | } 29 | ) 30 | .catch((error) => { 31 | if (error.response.status === 404) { 32 | setResultMessage(error.response.data.error); 33 | } 34 | setLoading(false); 35 | console.log(error); 36 | }); 37 | 38 | if (result) { 39 | if (result.error) { 40 | setResultMessage(result.error); 41 | } else { 42 | await props.fetchdataMethod(); 43 | } 44 | 45 | setLoading(false); 46 | } 47 | } 48 | 49 | if (loading) { 50 | return ; 51 | } 52 | 53 | return ( 54 |
55 |

{props.message}

56 | 57 | 65 |
66 | ); 67 | } 68 | 69 | export default ItemNotFound; 70 | -------------------------------------------------------------------------------- /src/pages/components/item-info/more-items.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import axios from "../../../lib/axios_instance"; 3 | 4 | import MoreItemCards from "./more-items/more-items-card"; 5 | 6 | import Config from "../../../lib/config"; 7 | import "../../css/users/user-details.css"; 8 | import i18next from "i18next"; 9 | 10 | function MoreItems(props) { 11 | const [data, setData] = useState(); 12 | const [config, setConfig] = useState(); 13 | 14 | 15 | useEffect(() => { 16 | 17 | const fetchConfig = async () => { 18 | try { 19 | const newConfig = await Config.getConfig(); 20 | setConfig(newConfig); 21 | } catch (error) { 22 | console.log(error); 23 | } 24 | }; 25 | 26 | 27 | const fetchData = async () => { 28 | if(config) 29 | { 30 | try { 31 | let url=`/api/getSeasons`; 32 | if(props.data.Type==='Season') 33 | { 34 | url=`/api/getEpisodes`; 35 | } 36 | 37 | const itemData = await axios.post(url, { 38 | Id: props.data.EpisodeId||props.data.Id 39 | },{ 40 | headers: { 41 | Authorization: `Bearer ${config.token}`, 42 | "Content-Type": "application/json", 43 | }, 44 | }); 45 | setData(itemData.data); 46 | } catch (error) { 47 | console.log(error); 48 | } 49 | } 50 | 51 | }; 52 | 53 | fetchData(); 54 | 55 | if (!config) { 56 | fetchConfig(); 57 | } 58 | 59 | const intervalId = setInterval(fetchData, 60000 * 5); 60 | return () => clearInterval(intervalId); 61 | }, [config, props]); 62 | 63 | 64 | if (!data || data.lenght===0) { 65 | return <>; 66 | } 67 | 68 | return ( 69 |
70 |

{props.data.Type==="Season" ? i18next.t("EPISODES") : i18next.t("SEASONS")}

71 |
72 | 73 | {data.sort((a,b) => a.IndexNumber-b.IndexNumber).map((item) => ( 74 | 75 | ))} 76 | 77 |
78 | 79 |
80 | ); 81 | } 82 | 83 | export default MoreItems; 84 | -------------------------------------------------------------------------------- /src/pages/components/library/last-watched.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import axios from "../../../lib/axios_instance"; 3 | import LastWatchedCard from "../general/last-watched-card"; 4 | 5 | 6 | import Config from "../../../lib/config"; 7 | import "../../css/users/user-details.css"; 8 | import { Trans } from "react-i18next"; 9 | 10 | function LibraryLastWatched(props) { 11 | const [data, setData] = useState(); 12 | const [config, setConfig] = useState(); 13 | 14 | 15 | useEffect(() => { 16 | 17 | const fetchConfig = async () => { 18 | try { 19 | const newConfig = await Config.getConfig(); 20 | setConfig(newConfig); 21 | } catch (error) { 22 | console.log(error); 23 | } 24 | }; 25 | 26 | const fetchData = async () => { 27 | try { 28 | const itemData = await axios.post(`/stats/getLibraryLastPlayed`, { 29 | libraryid: props.LibraryId, 30 | }, { 31 | headers: { 32 | Authorization: `Bearer ${config.token}`, 33 | "Content-Type": "application/json", 34 | }, 35 | }); 36 | setData(itemData.data); 37 | } catch (error) { 38 | console.log(error); 39 | } 40 | }; 41 | 42 | 43 | if (!config) { 44 | fetchConfig(); 45 | } 46 | 47 | if (!data && config) { 48 | fetchData(); 49 | } 50 | 51 | const intervalId = setInterval(fetchData, 60000 * 5); 52 | return () => clearInterval(intervalId); 53 | }, [data,config, props.LibraryId]); 54 | 55 | 56 | if (!data || !config) { 57 | return <>; 58 | } 59 | 60 | return ( 61 |
62 |

63 |
64 | {data.map((item) => ( 65 | 66 | ))} 67 | 68 |
69 | 70 |
71 | ); 72 | } 73 | 74 | export default LibraryLastWatched; 75 | -------------------------------------------------------------------------------- /src/pages/components/library/library-filter-modal.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | // import TableHead from "@mui/material/TableHead"; 3 | // import TableRow from "@mui/material/TableRow"; 4 | // import { Trans } from "react-i18next"; 5 | // import i18next from "i18next"; 6 | 7 | import Loading from "../general/loading"; 8 | import { Form } from "react-bootstrap"; 9 | 10 | function LibraryFilterModal(props) { 11 | if (!props || !props.libraries) { 12 | return ; 13 | } 14 | 15 | const handleLibrarySelection = (event) => { 16 | const selectedOptions = props.selectedLibraries.find((library) => library === event.target.value) 17 | ? props.selectedLibraries.filter((library) => library !== event.target.value) 18 | : [...props.selectedLibraries, event.target.value]; 19 | 20 | props.onSelectionChange(selectedOptions); 21 | }; 22 | 23 | return ( 24 |
25 |
26 | {props.libraries.map((library) => ( 27 | 36 | ))} 37 | 38 |
39 | ); 40 | } 41 | 42 | export default LibraryFilterModal; 43 | -------------------------------------------------------------------------------- /src/pages/components/library/recently-added.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import axios from "../../../lib/axios_instance"; 3 | 4 | import RecentlyAddedCard from "./RecentlyAdded/recently-added-card"; 5 | 6 | import "../../css/users/user-details.css"; 7 | import ErrorBoundary from "../general/ErrorBoundary"; 8 | import { Trans } from "react-i18next"; 9 | 10 | function RecentlyAdded(props) { 11 | const [data, setData] = useState(); 12 | const token = localStorage.getItem("token"); 13 | const groupRecentlyAdded = localStorage.getItem("groupRecentlyAdded") ?? true; 14 | 15 | useEffect(() => { 16 | const fetchData = async () => { 17 | try { 18 | let url = `/api/getRecentlyAdded?GroupResults=${groupRecentlyAdded}`; 19 | if (props.LibraryId) { 20 | url += `&libraryid=${props.LibraryId}`; 21 | } 22 | 23 | const itemData = await axios.get(url, { 24 | headers: { 25 | Authorization: `Bearer ${token}`, 26 | "Content-Type": "application/json", 27 | }, 28 | }); 29 | 30 | if (itemData && typeof itemData.data === "object" && Array.isArray(itemData.data)) { 31 | setData(itemData.data.filter((item) => ["Series", "Movie", "Audio", "Episode"].includes(item.Type))); 32 | } 33 | } catch (error) { 34 | console.log(error); 35 | } 36 | }; 37 | 38 | if (!data) { 39 | fetchData(); 40 | } 41 | 42 | const intervalId = setInterval(fetchData, 60000 * 5); 43 | return () => clearInterval(intervalId); 44 | }, [data, props.LibraryId, token]); 45 | 46 | if (!data) { 47 | return <>; 48 | } 49 | 50 | return ( 51 |
52 |

53 | 54 |

55 |
56 | {data && 57 | data.map((item) => ( 58 | 59 | 60 | 61 | ))} 62 |
63 |
64 | ); 65 | } 66 | 67 | export default RecentlyAdded; 68 | -------------------------------------------------------------------------------- /src/pages/components/settings/TerminalComponent.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import '../../css/websocket/websocket.css'; 3 | 4 | function TerminalComponent(props){ 5 | const [messages] = useState(props.data); 6 | 7 | 8 | return ( 9 |
10 |
11 | {messages && messages.map((message, index) => ( 12 |
13 |
{typeof message ==='object' ? message.Message :  message}
14 |
15 | ))} 16 |
17 |
18 | ); 19 | }; 20 | 21 | export default TerminalComponent; 22 | -------------------------------------------------------------------------------- /src/pages/components/settings/backup_page.jsx: -------------------------------------------------------------------------------- 1 | import { Col } from "react-bootstrap"; 2 | import BackupTables from "./backup_tables"; 3 | import BackupFiles from "./backupfiles"; 4 | 5 | export default function BackupPage() { 6 | return ( 7 | 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/components/settings/backup_tables.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import axios from "../../../lib/axios_instance"; 3 | 4 | import "../../css/settings/backups.css"; 5 | import { Trans } from "react-i18next"; 6 | import { Button } from "react-bootstrap"; 7 | 8 | const token = localStorage.getItem("token"); 9 | 10 | export default function BackupTables() { 11 | const [tables, setTables] = useState([]); 12 | 13 | const setTableExclusion = async (table) => { 14 | const tableData = await axios.post( 15 | `/api/setExcludedBackupTable`, 16 | { 17 | table: table, 18 | }, 19 | { 20 | headers: { 21 | Authorization: `Bearer ${token}`, 22 | "Content-Type": "application/json", 23 | }, 24 | } 25 | ); 26 | if (tableData.data) { 27 | setTables(tableData.data ?? []); 28 | } 29 | return; 30 | }; 31 | 32 | useEffect(() => { 33 | const fetchData = async () => { 34 | try { 35 | const backupTables = await axios.get(`/api/getBackupTables`, { 36 | headers: { 37 | Authorization: `Bearer ${token}`, 38 | "Content-Type": "application/json", 39 | }, 40 | }); 41 | setTables(backupTables.data); 42 | } catch (error) { 43 | console.log(error); 44 | } 45 | }; 46 | 47 | fetchData(); 48 | 49 | const intervalId = setInterval(fetchData, 60000 * 5); 50 | return () => clearInterval(intervalId); 51 | }, []); 52 | 53 | function toggleTable(table) { 54 | setTableExclusion(table); 55 | } 56 | 57 | return ( 58 |
59 |

60 | 61 |

62 |
63 | {tables.length > 0 && 64 | tables.map((table, index) => ( 65 | 74 | ))} 75 |
76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/pages/components/statCards/most_used_client.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import axios from "../../../lib/axios_instance"; 3 | import ItemStatComponent from "./ItemStatComponent"; 4 | 5 | 6 | 7 | import ComputerLineIcon from "remixicon-react/ComputerLineIcon"; 8 | import { Trans } from "react-i18next"; 9 | 10 | function MostUsedClient(props) { 11 | const [data, setData] = useState(); 12 | const [days, setDays] = useState(30); 13 | const token = localStorage.getItem('token'); 14 | 15 | useEffect(() => { 16 | 17 | const fetchLibraries = () => { 18 | const url = `/stats/getMostUsedClient`; 19 | 20 | axios 21 | .post(url, {days:props.days}, { 22 | headers: { 23 | Authorization: `Bearer ${token}`, 24 | "Content-Type": "application/json", 25 | }, 26 | }) 27 | .then((data) => { 28 | setData(data.data); 29 | }) 30 | .catch((error) => { 31 | console.log(error); 32 | }); 33 | }; 34 | 35 | 36 | if (!data) { 37 | fetchLibraries(); 38 | } 39 | if (days !== props.days) { 40 | setDays(props.days); 41 | fetchLibraries(); 42 | } 43 | 44 | 45 | const intervalId = setInterval(fetchLibraries, 60000 * 5); 46 | return () => clearInterval(intervalId); 47 | }, [data, days,props.days,token]); 48 | 49 | if (!data || data.length === 0) { 50 | return <>; 51 | } 52 | 53 | 54 | 55 | return ( 56 | } data={data} heading={} units={}/> 57 | ); 58 | } 59 | 60 | export default MostUsedClient; 61 | -------------------------------------------------------------------------------- /src/pages/components/statCards/mp_movies.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import axios from "../../../lib/axios_instance"; 3 | import Config from "../../../lib/config"; 4 | 5 | 6 | 7 | import ItemStatComponent from "./ItemStatComponent"; 8 | import Loading from "../general/loading"; 9 | import { Trans } from "react-i18next"; 10 | 11 | function MPMovies(props) { 12 | const [data, setData] = useState(); 13 | const [days, setDays] = useState(30); 14 | 15 | const [config, setConfig] = useState(null); 16 | 17 | 18 | useEffect(() => { 19 | const fetchConfig = async () => { 20 | try { 21 | const newConfig = await Config.getConfig(); 22 | setConfig(newConfig); 23 | } catch (error) { 24 | if (error.code === "ERR_NETWORK") { 25 | console.log(error); 26 | } 27 | } 28 | }; 29 | 30 | const fetchLibraries = () => { 31 | if (config) { 32 | const url = `/stats/getMostPopularByType`; 33 | 34 | axios 35 | .post(url, {days:props.days, type:'Movie'}, { 36 | headers: { 37 | Authorization: `Bearer ${config.token}`, 38 | "Content-Type": "application/json", 39 | }, 40 | }) 41 | .then((data) => { 42 | setData(data.data); 43 | }) 44 | .catch((error) => { 45 | console.log(error); 46 | }); 47 | } 48 | }; 49 | 50 | 51 | if (!config) { 52 | fetchConfig(); 53 | } 54 | 55 | if (!data) { 56 | fetchLibraries(); 57 | } 58 | 59 | if (days !== props.days) { 60 | setDays(props.days); 61 | fetchLibraries(); 62 | } 63 | 64 | 65 | const intervalId = setInterval(fetchLibraries, 60000 * 5); 66 | return () => clearInterval(intervalId); 67 | }, [data, config, days,props.days]); 68 | 69 | if (!data || data.length === 0) { 70 | return <>; 71 | } 72 | 73 | if(!config) 74 | { 75 | return ; 76 | } 77 | 78 | return ( 79 | } units={}/> 80 | ); 81 | } 82 | 83 | export default MPMovies; 84 | -------------------------------------------------------------------------------- /src/pages/components/statCards/mp_music.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | import axios from "../../../lib/axios_instance"; 4 | import Config from "../../../lib/config"; 5 | 6 | import ItemStatComponent from "./ItemStatComponent"; 7 | import { Trans } from "react-i18next"; 8 | 9 | 10 | 11 | function MPMusic(props) { 12 | const [data, setData] = useState(); 13 | const [days, setDays] = useState(30); 14 | const [config, setConfig] = useState(null); 15 | 16 | useEffect(() => { 17 | const fetchConfig = async () => { 18 | try { 19 | const newConfig = await Config.getConfig(); 20 | setConfig(newConfig); 21 | } catch (error) { 22 | if (error.code === "ERR_NETWORK") { 23 | console.log(error); 24 | } 25 | } 26 | }; 27 | 28 | const fetchLibraries = () => { 29 | if (config) { 30 | const url = `/stats/getMostPopularByType`; 31 | 32 | axios 33 | .post(url, { days: props.days, type:'Audio' }, { 34 | headers: { 35 | Authorization: `Bearer ${config.token}`, 36 | "Content-Type": "application/json", 37 | }, 38 | }) 39 | .then((data) => { 40 | setData(data.data); 41 | }) 42 | .catch((error) => { 43 | console.log(error); 44 | }); 45 | } 46 | }; 47 | 48 | if (!config) { 49 | fetchConfig(); 50 | } 51 | 52 | if (!data) { 53 | fetchLibraries(); 54 | } 55 | 56 | if (days !== props.days) { 57 | setDays(props.days); 58 | fetchLibraries(); 59 | } 60 | 61 | const intervalId = setInterval(fetchLibraries, 60000 * 5); 62 | return () => clearInterval(intervalId); 63 | }, [data, config, days,props.days]); 64 | 65 | if (!data || data.length === 0) { 66 | return <>; 67 | } 68 | 69 | 70 | 71 | 72 | return ( 73 | } units={} isAudio={true}/> 74 | ); 75 | } 76 | 77 | export default MPMusic; 78 | -------------------------------------------------------------------------------- /src/pages/components/statCards/mp_series.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import axios from "../../../lib/axios_instance"; 3 | import Config from "../../../lib/config"; 4 | import ItemStatComponent from "./ItemStatComponent"; 5 | import { Trans } from "react-i18next"; 6 | 7 | 8 | function MPSeries(props) { 9 | const [data, setData] = useState(); 10 | const [days, setDays] = useState(30); 11 | 12 | const [config, setConfig] = useState(null); 13 | 14 | useEffect(() => { 15 | const fetchConfig = async () => { 16 | try { 17 | const newConfig = await Config.getConfig(); 18 | setConfig(newConfig); 19 | } catch (error) { 20 | if (error.code === "ERR_NETWORK") { 21 | console.log(error); 22 | } 23 | } 24 | }; 25 | 26 | const fetchLibraries = () => { 27 | if (config) { 28 | const url = `/stats/getMostPopularByType`; 29 | 30 | axios 31 | .post(url, { days: props.days, type:'Series' }, { 32 | headers: { 33 | Authorization: `Bearer ${config.token}`, 34 | "Content-Type": "application/json", 35 | }, 36 | }) 37 | .then((data) => { 38 | setData(data.data); 39 | }) 40 | .catch((error) => { 41 | console.log(error); 42 | }); 43 | } 44 | }; 45 | 46 | if (!config) { 47 | fetchConfig(); 48 | } 49 | 50 | if (!data) { 51 | fetchLibraries(); 52 | } 53 | 54 | if (days !== props.days) { 55 | setDays(props.days); 56 | fetchLibraries(); 57 | } 58 | 59 | const intervalId = setInterval(fetchLibraries, 60000 * 5); 60 | return () => clearInterval(intervalId); 61 | }, [data, config, days,props.days]); 62 | 63 | if (!data || data.length === 0) { 64 | return <>; 65 | } 66 | 67 | 68 | 69 | return ( 70 | } units={}/> 71 | ); 72 | } 73 | 74 | export default MPSeries; 75 | -------------------------------------------------------------------------------- /src/pages/components/statCards/mv_libraries.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import axios from "../../../lib/axios_instance"; 3 | 4 | import ItemStatComponent from "./ItemStatComponent"; 5 | 6 | import TvLineIcon from "remixicon-react/TvLineIcon"; 7 | import FilmLineIcon from "remixicon-react/FilmLineIcon"; 8 | import FileMusicLineIcon from "remixicon-react/FileMusicLineIcon"; 9 | import CheckboxMultipleBlankLineIcon from "remixicon-react/CheckboxMultipleBlankLineIcon"; 10 | import { Trans } from "react-i18next"; 11 | 12 | function MVLibraries(props) { 13 | const [data, setData] = useState(); 14 | const [days, setDays] = useState(30); 15 | 16 | const token = localStorage.getItem('token'); 17 | 18 | useEffect(() => { 19 | 20 | const fetchLibraries = () => { 21 | const url = `/stats/getMostViewedLibraries`; 22 | 23 | axios 24 | .post(url, {days:props.days}, { 25 | headers: { 26 | Authorization: `Bearer ${token}`, 27 | "Content-Type": "application/json", 28 | }, 29 | }) 30 | .then((data) => { 31 | setData(data.data); 32 | }) 33 | .catch((error) => { 34 | console.log(error); 35 | }); 36 | }; 37 | 38 | 39 | 40 | if (!data) { 41 | fetchLibraries(); 42 | } 43 | if (days !== props.days) { 44 | setDays(props.days); 45 | fetchLibraries(); 46 | } 47 | 48 | const intervalId = setInterval(fetchLibraries, 60000 * 5); 49 | return () => clearInterval(intervalId); 50 | }, [data, days,props.days,token]); 51 | 52 | if (!data || data.length === 0) { 53 | return <>; 54 | } 55 | 56 | const SeriesIcon= ; 57 | const MovieIcon= ; 58 | const MusicIcon= ; 59 | const MixedIcon= ; 60 | 61 | 62 | return ( 63 | } units={}/> 64 | ); 65 | } 66 | 67 | export default MVLibraries; 68 | -------------------------------------------------------------------------------- /src/pages/components/statCards/mv_movies.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import axios from "../../../lib/axios_instance"; 3 | 4 | import Config from "../../../lib/config"; 5 | 6 | 7 | import ItemStatComponent from "./ItemStatComponent"; 8 | import { Trans } from "react-i18next"; 9 | 10 | 11 | function MVMusic(props) { 12 | 13 | const [data, setData] = useState(); 14 | const [days, setDays] = useState(30); 15 | 16 | const [config, setConfig] = useState(null); 17 | 18 | 19 | useEffect(() => { 20 | const fetchConfig = async () => { 21 | try { 22 | const newConfig = await Config.getConfig(); 23 | setConfig(newConfig); 24 | } catch (error) { 25 | if (error.code === "ERR_NETWORK") { 26 | console.log(error); 27 | } 28 | } 29 | }; 30 | 31 | const fetchLibraries = () => { 32 | if (config) { 33 | const url = `/stats/getMostViewedByType`; 34 | 35 | axios 36 | .post(url, {days:props.days, type:'Movie'}, { 37 | headers: { 38 | Authorization: `Bearer ${config.token}`, 39 | "Content-Type": "application/json", 40 | }, 41 | }) 42 | .then((data) => { 43 | setData(data.data); 44 | }) 45 | .catch((error) => { 46 | console.log(error); 47 | }); 48 | } 49 | }; 50 | 51 | 52 | if (!config) { 53 | fetchConfig(); 54 | } 55 | 56 | if (!data) { 57 | fetchLibraries(); 58 | } 59 | if (days !== props.days) { 60 | setDays(props.days); 61 | fetchLibraries(); 62 | } 63 | 64 | 65 | const intervalId = setInterval(fetchLibraries, 60000 * 5); 66 | return () => clearInterval(intervalId); 67 | }, [data, config, days,props.days]); 68 | 69 | if (!data || data.length === 0) { 70 | return <>; 71 | } 72 | 73 | 74 | 75 | 76 | return ( 77 | } units={}/> 78 | ); 79 | } 80 | 81 | export default MVMusic; 82 | -------------------------------------------------------------------------------- /src/pages/components/statCards/mv_music.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import axios from "../../../lib/axios_instance"; 3 | import Config from "../../../lib/config"; 4 | import ItemStatComponent from "./ItemStatComponent"; 5 | import { Trans } from "react-i18next"; 6 | 7 | function MVMovies(props) { 8 | const [data, setData] = useState(); 9 | const [days, setDays] = useState(30); 10 | 11 | const [config, setConfig] = useState(null); 12 | 13 | 14 | useEffect(() => { 15 | const fetchConfig = async () => { 16 | try { 17 | const newConfig = await Config.getConfig(); 18 | setConfig(newConfig); 19 | } catch (error) { 20 | if (error.code === "ERR_NETWORK") { 21 | console.log(error); 22 | } 23 | } 24 | }; 25 | 26 | const fetchLibraries = () => { 27 | if (config) { 28 | const url = `/stats/getMostViewedByType`; 29 | 30 | axios 31 | .post(url, {days:props.days, type:'Audio'}, { 32 | headers: { 33 | Authorization: `Bearer ${config.token}`, 34 | "Content-Type": "application/json", 35 | }, 36 | }) 37 | .then((data) => { 38 | setData(data.data); 39 | }) 40 | .catch((error) => { 41 | console.log(error); 42 | }); 43 | } 44 | }; 45 | 46 | 47 | if (!config) { 48 | fetchConfig(); 49 | } 50 | 51 | if (!data) { 52 | fetchLibraries(); 53 | } 54 | if (days !== props.days) { 55 | setDays(props.days); 56 | fetchLibraries(); 57 | } 58 | 59 | 60 | const intervalId = setInterval(fetchLibraries, 60000 * 5); 61 | return () => clearInterval(intervalId); 62 | }, [data, config, days,props.days]); 63 | 64 | if (!data || data.length === 0) { 65 | return <>; 66 | } 67 | 68 | 69 | 70 | 71 | return ( 72 | } units={} isAudio={true}/> 73 | ); 74 | } 75 | 76 | export default MVMovies; 77 | -------------------------------------------------------------------------------- /src/pages/components/statCards/mv_series.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import axios from "../../../lib/axios_instance"; 3 | import Config from "../../../lib/config"; 4 | 5 | 6 | import ItemStatComponent from "./ItemStatComponent"; 7 | import { Trans } from "react-i18next"; 8 | 9 | 10 | function MVSeries(props) { 11 | const [data, setData] = useState(); 12 | const [days, setDays] = useState(30); 13 | 14 | const [config, setConfig] = useState(null); 15 | 16 | 17 | useEffect(() => { 18 | const fetchConfig = async () => { 19 | try { 20 | const newConfig = await Config.getConfig(); 21 | setConfig(newConfig); 22 | } catch (error) { 23 | if (error.code === "ERR_NETWORK") { 24 | console.log(error); 25 | } 26 | } 27 | }; 28 | 29 | const fetchLibraries = () => { 30 | if (config) { 31 | const url = `/stats/getMostViewedByType`; 32 | 33 | axios 34 | .post(url, {days:props.days, type:'Series'}, { 35 | headers: { 36 | Authorization: `Bearer ${config.token}`, 37 | "Content-Type": "application/json", 38 | }, 39 | }) 40 | .then((data) => { 41 | setData(data.data); 42 | }) 43 | .catch((error) => { 44 | console.log(error); 45 | }); 46 | } 47 | }; 48 | 49 | 50 | if (!config) { 51 | fetchConfig(); 52 | } 53 | 54 | if (!data) { 55 | fetchLibraries(); 56 | } 57 | if (days !== props.days) { 58 | setDays(props.days); 59 | fetchLibraries(); 60 | } 61 | 62 | 63 | const intervalId = setInterval(fetchLibraries, 60000 * 5); 64 | return () => clearInterval(intervalId); 65 | }, [data, config, days,props.days]); 66 | 67 | if (!data || data.length === 0) { 68 | return <>; 69 | } 70 | 71 | 72 | return ( 73 | } units={}/> 74 | ); 75 | } 76 | 77 | export default MVSeries; 78 | -------------------------------------------------------------------------------- /src/pages/components/statistics/daily-play-count.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import axios from "../../../lib/axios_instance"; 3 | import Chart from "./chart"; 4 | 5 | import "../../css/stats.css"; 6 | import { Trans } from "react-i18next"; 7 | 8 | function DailyPlayStats(props) { 9 | 10 | const [stats, setStats] = useState(); 11 | const [libraries, setLibraries] = useState(); 12 | const [days, setDays] = useState(20); 13 | const token = localStorage.getItem("token"); 14 | 15 | 16 | 17 | 18 | useEffect(() => { 19 | const fetchLibraries = () => { 20 | const url = `/stats/getViewsOverTime?days=${props.days}`; 21 | 22 | axios 23 | .get( 24 | url, 25 | { 26 | headers: { 27 | Authorization: `Bearer ${token}`, 28 | "Content-Type": "application/json", 29 | }, 30 | } 31 | ) 32 | .then((data) => { 33 | setStats(data.data.stats); 34 | setLibraries(data.data.libraries); 35 | }) 36 | .catch((error) => { 37 | console.log(error); 38 | }); 39 | }; 40 | 41 | if (!stats) { 42 | fetchLibraries(); 43 | } 44 | if (days !== props.days) { 45 | setDays(props.days); 46 | fetchLibraries(); 47 | } 48 | 49 | const intervalId = setInterval(fetchLibraries, 60000 * 5); 50 | return () => clearInterval(intervalId); 51 | }, [stats,libraries, days, props.days, token]); 52 | 53 | if (!stats) { 54 | return <>; 55 | } 56 | 57 | if (stats.length === 0) { 58 | return ( 59 |
60 |

- {days} 1 ? 'S':''}`}/>

61 | 62 |
63 |
64 | ); 65 | } 66 | return ( 67 |
68 |

- {days} 1 ? 'S':''}`}/>

69 | 70 |
71 | 72 |
73 |
74 | ); 75 | } 76 | 77 | export default DailyPlayStats; 78 | -------------------------------------------------------------------------------- /src/pages/components/statistics/play-stats-by-day.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import axios from "../../../lib/axios_instance"; 3 | import Chart from "./chart"; 4 | 5 | import "../../css/stats.css"; 6 | import { Trans } from "react-i18next"; 7 | 8 | function PlayStatsByDay(props) { 9 | const [stats, setStats] = useState(); 10 | const [libraries, setLibraries] = useState(); 11 | const [days, setDays] = useState(20); 12 | const token = localStorage.getItem("token"); 13 | 14 | useEffect(() => { 15 | const fetchLibraries = () => { 16 | const url = `/stats/getViewsByDays?days=${props.days}`; 17 | 18 | axios 19 | .get( 20 | url, 21 | { 22 | headers: { 23 | Authorization: `Bearer ${token}`, 24 | "Content-Type": "application/json", 25 | }, 26 | } 27 | ) 28 | .then((data) => { 29 | setStats(data.data.stats); 30 | setLibraries(data.data.libraries); 31 | }) 32 | .catch((error) => { 33 | console.log(error); 34 | }); 35 | }; 36 | 37 | if (!stats) { 38 | fetchLibraries(); 39 | } 40 | if (days !== props.days) { 41 | setDays(props.days); 42 | fetchLibraries(); 43 | } 44 | 45 | const intervalId = setInterval(fetchLibraries, 60000 * 5); 46 | return () => clearInterval(intervalId); 47 | }, [stats, libraries, days, props.days, token]); 48 | 49 | if (!stats) { 50 | return <>; 51 | } 52 | 53 | if (stats.length === 0) { 54 | return ( 55 |
56 |

- {days} 1 ? 'S':''}`}/>

57 | 58 |
59 |
60 | ); 61 | } 62 | 63 | return ( 64 |
65 |

- {days} 1 ? 'S':''}`}/>

66 |
67 | 68 |
69 |
70 | ); 71 | } 72 | 73 | export default PlayStatsByDay; 74 | -------------------------------------------------------------------------------- /src/pages/components/statistics/play-stats-by-hour.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import axios from "../../../lib/axios_instance"; 3 | import Chart from "./chart"; 4 | import "../../css/stats.css"; 5 | import { Trans } from "react-i18next"; 6 | 7 | function PlayStatsByHour(props) { 8 | const [stats, setStats] = useState(); 9 | const [libraries, setLibraries] = useState(); 10 | const [days, setDays] = useState(20); 11 | const token = localStorage.getItem("token"); 12 | 13 | useEffect(() => { 14 | const fetchLibraries = () => { 15 | const url = `/stats/getViewsByHour?days=${props.days}`; 16 | 17 | axios 18 | .get( 19 | url, 20 | { 21 | headers: { 22 | Authorization: `Bearer ${token}`, 23 | "Content-Type": "application/json", 24 | }, 25 | } 26 | ) 27 | .then((data) => { 28 | setStats(data.data.stats); 29 | setLibraries(data.data.libraries); 30 | }) 31 | .catch((error) => { 32 | console.log(error); 33 | }); 34 | }; 35 | 36 | if (!stats) { 37 | fetchLibraries(); 38 | } 39 | if (days !== props.days) { 40 | setDays(props.days); 41 | fetchLibraries(); 42 | } 43 | 44 | const intervalId = setInterval(fetchLibraries, 60000 * 5); 45 | return () => clearInterval(intervalId); 46 | }, [stats, libraries, days, props.days, token]); 47 | 48 | if (!stats) { 49 | return <>; 50 | } 51 | 52 | if (stats.length === 0) { 53 | return ( 54 |
55 |

- {days} 1 ? 'S':''}`}/>

56 | 57 |
58 |
59 | ); 60 | } 61 | 62 | 63 | return ( 64 |
65 |

- {days} 1 ? 'S':''}`}/>

66 |
67 | 68 |
69 |
70 | ); 71 | } 72 | 73 | export default PlayStatsByHour; 74 | -------------------------------------------------------------------------------- /src/pages/components/user-info/genre-user-stats.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { Row, Col } from "react-bootstrap"; 3 | import GenreStatCard from "../statCards/genre-stat-card.jsx"; 4 | import { Trans } from "react-i18next"; 5 | import "../../css/genres.css"; 6 | import Config from "../../../lib/config"; 7 | import axios from "../../../lib/axios_instance"; 8 | 9 | function GenreUserStats(props) { 10 | const [data, setData] = useState(); 11 | const [config, setConfig] = useState(); 12 | 13 | useEffect(() => { 14 | const fetchConfig = async () => { 15 | try { 16 | const newConfig = await Config.getConfig(); 17 | setConfig(newConfig); 18 | } catch (error) { 19 | console.log(error); 20 | } 21 | }; 22 | 23 | const fetchData = async () => { 24 | if (config) { 25 | try { 26 | const itemData = await axios.get(`/stats/getGenreUserStats`, { 27 | params: { 28 | userid: props.UserId, 29 | }, 30 | headers: { 31 | Authorization: `Bearer ${config.token}`, 32 | "Content-Type": "application/json", 33 | }, 34 | }); 35 | const results = itemData.data.results || []; 36 | setData(results); 37 | } catch (error) { 38 | console.log(error); 39 | } 40 | } 41 | }; 42 | 43 | if (!data) { 44 | fetchData(); 45 | } 46 | 47 | if (!config) { 48 | fetchConfig(); 49 | } 50 | 51 | const intervalId = setInterval(fetchData, 60000 * 5); 52 | return () => clearInterval(intervalId); 53 | }, [data, config, props.UserId]); 54 | 55 | if (!data || data.length == 0 || !config) { 56 | return <>; 57 | } 58 | return ( 59 |
60 |

61 | 62 |

63 |
64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 |
73 |
74 | ); 75 | } 76 | 77 | export default GenreUserStats; 78 | -------------------------------------------------------------------------------- /src/pages/components/user-info/lastplayed.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import axios from "../../../lib/axios_instance"; 3 | import LastWatchedCard from "../general/last-watched-card"; 4 | import ErrorBoundary from "../general/ErrorBoundary"; 5 | 6 | import Config from "../../../lib/config"; 7 | import "../../css/users/user-details.css"; 8 | import { Trans } from "react-i18next"; 9 | 10 | function LastPlayed(props) { 11 | const [data, setData] = useState(); 12 | const [config, setConfig] = useState(); 13 | 14 | useEffect(() => { 15 | const fetchConfig = async () => { 16 | try { 17 | const newConfig = await Config.getConfig(); 18 | setConfig(newConfig); 19 | } catch (error) { 20 | console.log(error); 21 | } 22 | }; 23 | 24 | const fetchData = async () => { 25 | if (config) { 26 | try { 27 | const itemData = await axios.post( 28 | `/stats/getUserLastPlayed`, 29 | { 30 | userid: props.UserId, 31 | }, 32 | { 33 | headers: { 34 | Authorization: `Bearer ${config.token}`, 35 | "Content-Type": "application/json", 36 | }, 37 | } 38 | ); 39 | setData(itemData.data); 40 | } catch (error) { 41 | console.log(error); 42 | } 43 | } 44 | }; 45 | 46 | if (!data) { 47 | fetchData(); 48 | } 49 | 50 | if (!config) { 51 | fetchConfig(); 52 | } 53 | 54 | const intervalId = setInterval(fetchData, 60000 * 5); 55 | return () => clearInterval(intervalId); 56 | }, [data, config, props.UserId]); 57 | 58 | if (!data || !config) { 59 | return <>; 60 | } 61 | 62 | return ( 63 |
64 |

65 | 66 |

67 |
68 | {data.map((item, index) => ( 69 | 70 | 71 | 72 | ))} 73 |
74 |
75 | ); 76 | } 77 | 78 | export default LastPlayed; 79 | -------------------------------------------------------------------------------- /src/pages/css/about.css: -------------------------------------------------------------------------------- 1 | @import './variables.module.css'; 2 | .about 3 | { 4 | background-color: var(--second-background-color) !important; 5 | border-color: transparent !important; 6 | color: white !important; 7 | 8 | 9 | } 10 | 11 | .about a 12 | { 13 | text-decoration: none; 14 | } 15 | 16 | .about a:hover 17 | { 18 | text-decoration: underline; 19 | } 20 | 21 | -------------------------------------------------------------------------------- /src/pages/css/activity.css: -------------------------------------------------------------------------------- 1 | .Activity { 2 | /* margin-top: 10px; */ 3 | position: relative; 4 | } 5 | -------------------------------------------------------------------------------- /src/pages/css/activity/stream-info.css: -------------------------------------------------------------------------------- 1 | @import '../variables.module.css'; 2 | 3 | .ellipse 4 | { 5 | display: -webkit-box; 6 | -webkit-box-orient: vertical; 7 | -webkit-line-clamp: 1; 8 | overflow: hidden; 9 | text-overflow: ellipsis; 10 | } 11 | -------------------------------------------------------------------------------- /src/pages/css/error.css: -------------------------------------------------------------------------------- 1 | .error 2 | { 3 | margin: 0px; 4 | height: calc(100vh - 100px); 5 | 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: center; 9 | align-items: center; 10 | /* z-index: 9999; */ 11 | background-color: #1e1c22; 12 | transition: opacity 800ms ease-in; 13 | opacity: 1; 14 | color: white; 15 | } 16 | 17 | 18 | .error .message 19 | { 20 | color:crimson; 21 | font-size: 1.5em; 22 | font-weight: 500; 23 | } 24 | 25 | .error-title 26 | { 27 | color:crimson; 28 | font-weight: 500; 29 | } -------------------------------------------------------------------------------- /src/pages/css/genres.css: -------------------------------------------------------------------------------- 1 | @import "./variables.module.css"; 2 | .genre-container { 3 | display: flex; 4 | overflow-x: none; 5 | background-color: var(--secondary-background-color); 6 | padding: 20px; 7 | border-radius: 8px; 8 | color: white; 9 | margin-bottom: 20px; 10 | width: 100%; 11 | height: 100%; 12 | } 13 | 14 | .radial-tooltip { 15 | background-color: black; 16 | border-radius: 8px; 17 | padding: 10px; 18 | } 19 | 20 | .radial-tooltip > p { 21 | padding: 0px !important; 22 | margin: 0px !important; 23 | } 24 | 25 | .radial-tooltip > .tooltip-header { 26 | color: var(--secondary-color); 27 | } 28 | 29 | .genre-stats { 30 | width: 90%; 31 | height: 400px; 32 | } 33 | 34 | .genre-container::-webkit-scrollbar { 35 | width: 5px; /* set scrollbar width */ 36 | } 37 | 38 | .genre-container::-webkit-scrollbar-track { 39 | background-color: transparent; /* set track color */ 40 | } 41 | 42 | .genre-container::-webkit-scrollbar-thumb { 43 | background-color: #8888884d; /* set thumb color */ 44 | border-radius: 5px; /* round corners */ 45 | width: 5px; 46 | } 47 | 48 | .genre-container::-webkit-scrollbar-thumb:hover { 49 | background-color: #88888883; /* set thumb color */ 50 | } 51 | -------------------------------------------------------------------------------- /src/pages/css/globalstats.css: -------------------------------------------------------------------------------- 1 | @import "./variables.module.css"; 2 | .global-stats-container { 3 | display: grid; 4 | grid-template-columns: repeat(auto-fit, minmax(350px, 400px)); 5 | grid-auto-rows: 120px; 6 | background-color: var(--secondary-background-color); 7 | padding: 20px; 8 | border-radius: 8px; 9 | font-size: 1.3em; 10 | } 11 | 12 | .global-stats { 13 | color: white; 14 | width: fit-content; 15 | max-width: 400px; 16 | min-width: 350px; 17 | height: 100px; 18 | padding: 5px; 19 | } 20 | 21 | .play-duration-stats { 22 | padding-top: 10px; 23 | display: flex; 24 | flex-direction: column; 25 | align-items: start; 26 | } 27 | 28 | .stat-value { 29 | text-align: right; 30 | color: var(--secondary-color); 31 | font-weight: 500; 32 | font-size: 1.1em; 33 | margin: 0; 34 | } 35 | .stat-unit { 36 | padding-inline: 5px; 37 | margin: 0; 38 | } 39 | 40 | .time-part { 41 | display: flex; 42 | flex-direction: row; 43 | } 44 | -------------------------------------------------------------------------------- /src/pages/css/home.css: -------------------------------------------------------------------------------- 1 | .Home 2 | { 3 | color: white; 4 | margin-bottom: 20px; 5 | } -------------------------------------------------------------------------------- /src/pages/css/items/item-details.css: -------------------------------------------------------------------------------- 1 | @import "../variables.module.css"; 2 | .item-detail-container { 3 | color: white; 4 | background-color: var(--secondary-background-color); 5 | margin: 20px 0; 6 | } 7 | 8 | .item-banner-image { 9 | margin-right: 20px; 10 | } 11 | 12 | .item-name { 13 | display: -webkit-box; 14 | -webkit-box-orient: vertical; 15 | -webkit-line-clamp: 1; 16 | overflow: hidden; 17 | text-overflow: ellipsis; 18 | } 19 | .item-image { 20 | max-width: 200px; 21 | border-radius: 8px; 22 | object-fit: cover; 23 | } 24 | 25 | .item-details div a { 26 | text-decoration: none !important; 27 | color: white !important; 28 | } 29 | 30 | .item-details div a:hover { 31 | color: var(--secondary-color) !important; 32 | } 33 | 34 | .hide-tab-titles { 35 | display: none !important; 36 | } 37 | -------------------------------------------------------------------------------- /src/pages/css/items/item-stat-component.css: -------------------------------------------------------------------------------- 1 | .overflow-text { 2 | white-space: nowrap; 3 | overflow: hidden; 4 | text-overflow: ellipsis; 5 | } 6 | -------------------------------------------------------------------------------- /src/pages/css/library/libraries.css: -------------------------------------------------------------------------------- 1 | .libraries-container 2 | { 3 | color: white; 4 | display: grid; 5 | grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); 6 | grid-gap: 20px; 7 | 8 | 9 | } 10 | 11 | .tooltip-icon-button 12 | { 13 | opacity: 100 !important; 14 | } 15 | 16 | .tooltip-icon-button > .tooltip-inner 17 | { 18 | background-color: transparent; 19 | 20 | } 21 | 22 | .tooltip .tooltip-arrow::before 23 | { 24 | border-style: none !important; 25 | } -------------------------------------------------------------------------------- /src/pages/css/library/library-card.css: -------------------------------------------------------------------------------- 1 | @import '../variables.module.css'; 2 | 3 | .lib-card{ 4 | color: white; 5 | /* max-width: 400px; */ 6 | 7 | } 8 | 9 | .card-label 10 | { 11 | color: var(--secondary-color); 12 | } 13 | 14 | .card-row .col 15 | { 16 | margin: 10px; 17 | } 18 | 19 | 20 | .library-card-image 21 | { 22 | max-height: 170px; 23 | 24 | overflow: hidden; 25 | border-radius: 8px 8px 0px 0px; 26 | 27 | } 28 | .library-card-banner 29 | { 30 | object-fit: cover; 31 | background-color: black; 32 | background-repeat: no-repeat; 33 | background-size: cover; 34 | transition: all 0.2s ease-in-out; 35 | max-height: 170px; 36 | height: 170px; 37 | 38 | } 39 | 40 | .library-card-banner-hover:hover, .default_library_image_hover:hover 41 | { 42 | opacity: 0.5; 43 | } 44 | 45 | .library-card-details 46 | { 47 | background-color: var(--secondary-background-color) !important; 48 | } 49 | 50 | .library-card-details-inv 51 | { 52 | background-color: var(--background-color) !important; 53 | } 54 | 55 | 56 | 57 | .default_library_image 58 | { 59 | background-color: var(--secondary-background-color); 60 | width: 100%; 61 | height: 170px; 62 | border-radius: 8px 8px 0px 0px; 63 | transition: all 0.2s ease-in-out; 64 | } 65 | 66 | .default_library_image-inv 67 | { 68 | background-color: var(--background-color); 69 | width: 100%; 70 | height: 170px; 71 | border-radius: 8px 8px 0px 0px; 72 | transition: all 0.2s ease-in-out; 73 | } 74 | 75 | .form-switch .form-check-input { 76 | border-color: var(--primary-color) !important; 77 | } 78 | 79 | .form-switch .form-check-input:checked { 80 | background-color: var(--primary-color); 81 | } 82 | 83 | .form-switch .form-check-input:focus { 84 | box-shadow: none !important; 85 | border-color: var(--primary-color) !important; 86 | } 87 | 88 | .form-switch .form-check-input:hover { 89 | box-shadow: none !important; 90 | border-color: var(--primary-color) !important; 91 | } 92 | 93 | -------------------------------------------------------------------------------- /src/pages/css/library/media-items.css: -------------------------------------------------------------------------------- 1 | @import '../variables.module.css'; 2 | .media-items-container { 3 | 4 | display: grid; 5 | grid-template-columns: repeat(auto-fit, minmax(150px, 150px)); 6 | grid-gap: 20px; 7 | 8 | background-color: var(--secondary-background-color); 9 | padding: 20px; 10 | border-radius: 8px; 11 | color: white; 12 | margin-bottom: 20px; 13 | min-height: 300px; 14 | justify-content: space-between; 15 | } 16 | 17 | .media-items-container::-webkit-scrollbar { 18 | width: 5px; /* set scrollbar width */ 19 | } 20 | 21 | .media-items-container::-webkit-scrollbar-track { 22 | background-color: transparent; /* set track color */ 23 | } 24 | 25 | .media-items-container::-webkit-scrollbar-thumb { 26 | background-color: #8888884d; /* set thumb color */ 27 | border-radius: 5px; /* round corners */ 28 | width: 5px; 29 | } 30 | 31 | 32 | 33 | .media-items-container::-webkit-scrollbar-thumb:hover { 34 | background-color: #88888883; /* set thumb color */ 35 | } 36 | 37 | .library-items > div>div> .form-control 38 | { 39 | color: white !important; 40 | background-color: var(--secondary-background-color) !important; 41 | border-color: var(--secondary-background-color) !important; 42 | } 43 | 44 | .library-items > div>div> .form-control::placeholder 45 | { 46 | color: white !important; 47 | 48 | } 49 | 50 | 51 | 52 | .library-items > div> div>.form-control:focus 53 | { 54 | box-shadow: none !important; 55 | border-color: var(--primary-color) !important; 56 | } -------------------------------------------------------------------------------- /src/pages/css/libraryOverview.css: -------------------------------------------------------------------------------- 1 | .overview-container 2 | { 3 | display: grid; 4 | grid-template-columns: repeat(auto-fit, minmax(auto, 520px)); 5 | grid-auto-rows: 200px;/* max-width+offset so 215 + 20*/ 6 | 7 | background-color: var(--secondary-background-color); 8 | border-radius: 8px; 9 | padding: 20px; 10 | } 11 | 12 | .library-stat-card 13 | { 14 | width: 500px; 15 | height: 180px; 16 | display: flex; 17 | color: white; 18 | /* box-shadow: 0 0 20px 5px rgba(0, 0, 0, 0.5); */ 19 | background: linear-gradient(to right, #00A4DC, #AA5CC3); 20 | background-size: cover; 21 | } 22 | 23 | 24 | 25 | .library-icons 26 | { 27 | display: flex; 28 | justify-content: center; 29 | align-items: center; 30 | height: 100%; 31 | 32 | } 33 | 34 | .library-header { 35 | display: flex; 36 | justify-content: space-between; 37 | color: white; 38 | font-weight: 500; 39 | } 40 | .library-header-count { 41 | color: lightgray; 42 | font-weight: 300; 43 | 44 | } 45 | 46 | .library-item { 47 | display: flex; 48 | justify-content: space-between; 49 | width: 100%; 50 | height: 20px; 51 | margin-bottom: 5px; 52 | 53 | } 54 | 55 | .library-item-index { 56 | padding-top: 3px; 57 | font-size: 0.8em; 58 | padding-right: 2px; 59 | color: grey; 60 | 61 | text-align: right; 62 | } 63 | .library-item-name { 64 | width: 35%; 65 | } 66 | 67 | .library-item-count { 68 | width: 60%; 69 | text-align: right; 70 | color: #00A4DC; 71 | font-weight: 500; 72 | font-size: 1.1em; 73 | 74 | } 75 | 76 | .library-image 77 | { 78 | display: flex; 79 | justify-content: center; 80 | align-items: center; 81 | height: 180px; 82 | width: 180px; 83 | background-color: rgb(0, 0, 0, 0.6); 84 | } 85 | 86 | .library-banner-image 87 | { 88 | height: 180px; 89 | width: 120px; 90 | } 91 | 92 | 93 | .library-user-image 94 | { 95 | 96 | border-radius: 50%; 97 | width: 80%; 98 | object-fit: cover; 99 | 100 | } 101 | 102 | 103 | .library{ 104 | width: 100%; 105 | padding: 5px 20px; 106 | backdrop-filter: blur(8px); 107 | 108 | background-color: rgb(0, 0, 0, 0.6); 109 | } 110 | 111 | -------------------------------------------------------------------------------- /src/pages/css/loading.css: -------------------------------------------------------------------------------- 1 | @import "./variables.module.css"; 2 | .loading { 3 | margin: 0px; 4 | height: calc(100vh - 100px); 5 | 6 | display: flex; 7 | justify-content: center; 8 | align-items: center; 9 | background-color: var(--background-color); 10 | transition: opacity 800ms ease-in; 11 | opacity: 1; 12 | } 13 | 14 | .busy { 15 | height: auto; 16 | position: absolute; 17 | top: 0; 18 | left: 0; 19 | right: 0; 20 | bottom: 55px; 21 | display: flex; 22 | justify-content: center; 23 | align-items: center; 24 | background-color: rgba(0, 0, 0, 0.5); /* Semi-transparent background */ 25 | z-index: 9999; /* High z-index to be above other elements */ 26 | } 27 | 28 | .loading::before { 29 | opacity: 0; 30 | } 31 | 32 | .loading__spinner { 33 | width: 50px; 34 | height: 50px; 35 | border: 5px solid #ccc; 36 | border-top-color: #333; 37 | border-radius: 50%; 38 | animation: spin 1s ease-in-out infinite; 39 | } 40 | 41 | @keyframes spin { 42 | from { 43 | transform: rotate(0deg); 44 | } 45 | to { 46 | transform: rotate(360deg); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/pages/css/navbar.css: -------------------------------------------------------------------------------- 1 | @import './variables.module.css'; 2 | .navbar { 3 | background-color: var(--secondary-background-color); 4 | border-right: 1px solid #414141 !important; 5 | } 6 | 7 | @media (min-width: 768px) { 8 | .navbar { 9 | min-height: 100vh; 10 | border-bottom: 1px solid #414141 !important; 11 | 12 | } 13 | } 14 | 15 | .navbar .navbar-brand{ 16 | margin-top: 20px; 17 | font-size: 32px; 18 | font-weight: 500; 19 | } 20 | 21 | .navbar .navbar-nav{ 22 | width: 100%; 23 | margin-top: 20px; 24 | } 25 | 26 | .logout 27 | { 28 | 29 | color: var(--secondary-color) !important; 30 | } 31 | .navbar-toggler > .collapsed 32 | { 33 | right: 0; 34 | } 35 | /* .navbar-toggler-icon 36 | { 37 | width: 100% !important; 38 | } */ 39 | 40 | 41 | 42 | .navitem { 43 | 44 | color: white; 45 | font-size: 18px !important; 46 | text-decoration: none; 47 | margin-right: 4px; 48 | background-color: var(--background-color) !important; 49 | transition: all 0.4s ease-in-out; 50 | border-radius: 8px; 51 | margin-bottom: 10px; 52 | width: 90%; 53 | } 54 | 55 | @media (min-width: 768px) { 56 | .navitem { 57 | border-radius: 0 8px 8px 0; 58 | } 59 | } 60 | 61 | .navitem:hover { 62 | background-color: var(--primary-color); 63 | } 64 | 65 | .active 66 | { 67 | background-color: var(--primary-color) !important; 68 | transition: background-color 0.2s ease-in-out; 69 | } 70 | 71 | 72 | .nav-link 73 | { 74 | display: flex !important; 75 | } 76 | 77 | 78 | .nav-text { 79 | margin-left: 10px; 80 | } 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /src/pages/css/recent.css: -------------------------------------------------------------------------------- 1 | .recent { 2 | display: grid; 3 | grid-template-columns: repeat(auto-fit, minmax(185px, 200px)); 4 | grid-auto-rows: 340px;/* max-width+offset so 215 + 20*/ 5 | background-color: rgba(0,0,0,0.5); 6 | padding: 20px; 7 | border-radius: 8px; 8 | margin-right: 20px; 9 | color: white; 10 | 11 | 12 | } 13 | 14 | 15 | 16 | .recent-card 17 | { 18 | display: flex; 19 | flex-direction: column; 20 | 21 | box-shadow: 0 0 20px rgba(255, 255, 255, 0.05); 22 | 23 | height: 320px; 24 | width: 185px; 25 | border-radius: 8px; 26 | 27 | } 28 | 29 | 30 | .recent-card-banner { 31 | width: 100%; 32 | height: 70%; 33 | background-size: cover; 34 | background-repeat: no-repeat; 35 | background-position: center top; 36 | border-radius: 8px 8px 0px 0px; 37 | 38 | } 39 | 40 | .recent-card-details { 41 | 42 | 43 | width: 100%; 44 | height: 30%; 45 | position: relative; 46 | 47 | } 48 | 49 | .recent-card-item-name { 50 | width: 185px; 51 | overflow: hidden; 52 | text-overflow: ellipsis; 53 | position: absolute; 54 | margin: 0; 55 | 56 | } 57 | 58 | .recent-card-last-played{ 59 | width: 185px; 60 | overflow: hidden; 61 | text-overflow: ellipsis; 62 | position: absolute; 63 | bottom: 0; 64 | margin: 0; 65 | } 66 | 67 | -------------------------------------------------------------------------------- /src/pages/css/settings/backups.css: -------------------------------------------------------------------------------- 1 | @import '../variables.module.css'; 2 | tr{ 3 | color: white; 4 | } 5 | 6 | th:hover{ 7 | border-bottom: none !important; 8 | } 9 | 10 | th{ 11 | border-bottom: none !important; 12 | cursor: default !important; 13 | } 14 | 15 | .backup-file-download 16 | { 17 | cursor: pointer; 18 | } 19 | 20 | td{ 21 | border-bottom: none !important; 22 | } 23 | 24 | .upload-file 25 | { 26 | background-color: var(--secondary-background-color) !important; 27 | border-color: var(--secondary-background-color) !important; 28 | color: white !important; 29 | } 30 | 31 | .upload-file:focus 32 | { 33 | box-shadow: none !important; 34 | } 35 | -------------------------------------------------------------------------------- /src/pages/css/settings/settings.css: -------------------------------------------------------------------------------- 1 | @import '../variables.module.css'; 2 | .show-key 3 | { 4 | margin-bottom: 20px;; 5 | } 6 | 7 | 8 | .settings{ 9 | background-color: var(--secondary-background-color); 10 | padding: 20px; 11 | border-radius: 8px; 12 | } 13 | 14 | .tasks { 15 | 16 | color: white; 17 | /* margin-inline: 10px; */ 18 | 19 | } 20 | 21 | 22 | .settings-form { 23 | 24 | color: white; 25 | margin-top: 20px; 26 | margin-inline: 10px; 27 | 28 | } 29 | 30 | 31 | .form-row 32 | { 33 | margin-top: 20px; 34 | display: grid; 35 | grid-template-columns: repeat(3,minmax(0,1fr)); 36 | align-items: flex-start; 37 | gap: 20px; 38 | } 39 | 40 | .form-row label { 41 | font-weight: bold; 42 | font-size: 18px; 43 | } 44 | 45 | .form-row input { 46 | padding: 5px; 47 | border-radius: 5px; 48 | border: none; 49 | max-width: 700px; 50 | } 51 | 52 | 53 | .submit 54 | { 55 | font-weight: bold; 56 | margin-bottom: 5px; 57 | } 58 | 59 | 60 | 61 | 62 | .settings-form > div> div> .form-control, 63 | .settings-form > div> div> .input-group> .form-control 64 | { 65 | color: white !important; 66 | background-color: var(--background-color) !important; 67 | border-color: var(--background-color) !important; 68 | } 69 | 70 | .settings-form > div> div> .input-group> .btn 71 | { 72 | border: none !important; 73 | } 74 | 75 | 76 | .settings-form > div> div> .form-control:focus, 77 | .settings-form > div> div> .input-group> .form-control:focus 78 | { 79 | box-shadow: none !important; 80 | border-color: var(--primary-color) !important; 81 | } 82 | 83 | .dropdown-item, .dropdown-menu 84 | { 85 | background-color: var(--background-color) !important; 86 | color: white !important; 87 | 88 | } 89 | 90 | .dropdown-item:hover 91 | { 92 | background-color: var(--primary-color) !important; 93 | color: white !important; 94 | } 95 | -------------------------------------------------------------------------------- /src/pages/css/settings/version.css: -------------------------------------------------------------------------------- 1 | @import '../variables.module.css'; 2 | .version 3 | { 4 | background-color: var(--background-color) !important; 5 | 6 | color: white !important; 7 | position: fixed !important; 8 | bottom: 0; 9 | max-width: 200px; 10 | text-align: center; 11 | width: 100%; 12 | 13 | 14 | } 15 | 16 | 17 | .version a 18 | { 19 | text-decoration: none; 20 | } 21 | 22 | .version a:hover 23 | { 24 | text-decoration: underline; 25 | } 26 | 27 | .nav-pills > .nav-item , .nav-pills > .nav-item > .nav-link 28 | { 29 | color: white !important; 30 | } 31 | 32 | .nav-pills > .nav-item .active 33 | { 34 | background-color: var(--primary-color) !important; 35 | color: white !important; 36 | } 37 | 38 | .nav-pills > .nav-item :hover 39 | { 40 | background-color: var(--primary-color) !important; 41 | } 42 | 43 | .nav-pills > .nav-item .active> .nav-link 44 | { 45 | color: white; 46 | } -------------------------------------------------------------------------------- /src/pages/css/stats.css: -------------------------------------------------------------------------------- 1 | @import './variables.module.css'; 2 | .watch-stats 3 | { 4 | margin-top: 10px; 5 | } 6 | 7 | .graph 8 | { 9 | 10 | height: 700px; 11 | color:black !important; 12 | background-color: var(--secondary-background-color); 13 | padding:10px; 14 | border-radius:8px; 15 | } 16 | 17 | 18 | 19 | .small 20 | { 21 | height: 500px; 22 | 23 | } 24 | 25 | .statistics-graphs 26 | { 27 | display: grid; 28 | grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 29 | grid-gap: 20px; 30 | margin-bottom: 20px; 31 | 32 | 33 | } 34 | 35 | .main-widget 36 | { 37 | flex: 1; 38 | } 39 | 40 | .main-widget h1 41 | { 42 | margin-bottom: 10px !important; 43 | } 44 | 45 | 46 | .statistics-widget h1{ 47 | margin-bottom: 10px !important; 48 | } 49 | 50 | .chart-canvas { 51 | width: 100%; 52 | height: 400px; 53 | } 54 | 55 | @media (min-width: 768px) { 56 | .chart-canvas { 57 | height: 500px; 58 | } 59 | } 60 | 61 | @media (min-width: 992px) { 62 | .chart-canvas { 63 | height: 600px; 64 | } 65 | } -------------------------------------------------------------------------------- /src/pages/css/timeline/activity-timeline.css: -------------------------------------------------------------------------------- 1 | @import "../variables.module.css"; 2 | 3 | .Heading { 4 | justify-content: space-between; 5 | flex-wrap: wrap; 6 | @media (max-width: 576px) { 7 | h1 { 8 | width: 100%; 9 | padding-bottom: 1rem; 10 | } 11 | * { 12 | flex-grow: 1; 13 | } 14 | } 15 | } 16 | .activity-card { 17 | display: flex; 18 | width: 10rem; 19 | * { 20 | flex-grow: 1; 21 | border-radius: var(--bs-border-radius-lg) !important; 22 | } 23 | .activity-card-img { 24 | object-fit: cover; 25 | background-color: black; 26 | background-repeat: no-repeat; 27 | background-size: cover; 28 | transition: all 0.2s ease-in-out; 29 | } 30 | } 31 | 32 | .MuiTimelineItem-root { 33 | height: 20rem; 34 | @media (max-width: 576px) { 35 | height: 25rem; 36 | ::before { 37 | padding: 0; 38 | flex: 0; 39 | } 40 | .MuiTimelineSeparator-root { 41 | flex: 1; 42 | } 43 | .MuiTimelineOppositeContent-root { 44 | display: none; 45 | } 46 | .activity-description { 47 | max-width: 50%; 48 | padding: 0.5rem 0; 49 | text-align: center; 50 | } 51 | } 52 | } 53 | 54 | .MuiTimelineContent-root { 55 | align-self: center; 56 | } 57 | -------------------------------------------------------------------------------- /src/pages/css/users/user-activity.css: -------------------------------------------------------------------------------- 1 | .Activity > div > div > .form-control { 2 | color: white !important; 3 | background-color: var(--secondary-background-color) !important; 4 | border-color: var(--secondary-background-color) !important; 5 | } 6 | 7 | .Activity > div > div > .form-control::placeholder { 8 | color: white !important; 9 | } 10 | 11 | .Activity > div > div > .form-control:focus { 12 | box-shadow: none !important; 13 | border-color: var(--primary-color) !important; 14 | } 15 | -------------------------------------------------------------------------------- /src/pages/css/users/user-details.css: -------------------------------------------------------------------------------- 1 | @import "../variables.module.css"; 2 | 3 | .user-detail-container { 4 | color: white; 5 | background-color: var(--secondary-background-color); 6 | padding: 20px; 7 | margin: 20px 0; 8 | border-radius: 8px; 9 | 10 | display: flex; 11 | align-items: center; 12 | } 13 | 14 | .user-name { 15 | font-size: 2.5em; 16 | font-weight: 500; 17 | margin: 0; 18 | } 19 | .user-image { 20 | width: 100px; 21 | height: 100px; 22 | border-radius: 50%; 23 | object-fit: cover; 24 | box-shadow: 0 0 10px 5px var(--secondary-background-color); 25 | } 26 | 27 | .user-image-container { 28 | width: 100px; 29 | height: 100px; 30 | margin-right: 20px; 31 | } 32 | -------------------------------------------------------------------------------- /src/pages/css/users/users.css: -------------------------------------------------------------------------------- 1 | .card-user-image { 2 | border-radius: 50%; 3 | width: 30px; 4 | height: 30px; 5 | object-fit: cover; 6 | } 7 | .Users > div > div > .form-control { 8 | color: white !important; 9 | background-color: var(--secondary-background-color) !important; 10 | border-color: var(--secondary-background-color) !important; 11 | } 12 | 13 | .Users > div > div > .form-control::placeholder { 14 | color: white !important; 15 | } 16 | 17 | .Users > div > div > .form-control:focus { 18 | box-shadow: none !important; 19 | border-color: var(--primary-color) !important; 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/css/variables.module.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: #5a2da5; 3 | --secondary-color: #00A4DC; 4 | --background-color: #1e1c22; 5 | --secondary-background-color: #2c2a2f; 6 | --tertiary-background-color: #2f2e31; 7 | } -------------------------------------------------------------------------------- /src/pages/css/websocket/websocket.css: -------------------------------------------------------------------------------- 1 | .console-container { 2 | background-color: black; 3 | color: white; 4 | height: 500px; 5 | overflow-y: auto; 6 | margin-top: 20px; 7 | padding: 10px; 8 | border-radius: 8px; 9 | } 10 | 11 | .console-message { 12 | margin-bottom: 10px; 13 | font-size: 1.1rem; 14 | } 15 | 16 | .console-text { 17 | margin: 0; 18 | font-family: monospace; 19 | text-overflow: ellipsis; 20 | overflow-x: hidden; 21 | } 22 | 23 | 24 | 25 | .console-container::-webkit-scrollbar { 26 | width: 10px; /* set scrollbar width */ 27 | } 28 | 29 | .console-container::-webkit-scrollbar-track { 30 | background-color: transparent; /* set track color */ 31 | } 32 | 33 | .console-container::-webkit-scrollbar-thumb { 34 | background-color: #8888884d; /* set thumb color */ 35 | border-radius: 5px; /* round corners */ 36 | } 37 | 38 | .console-container::-webkit-scrollbar-thumb:hover { 39 | background-color: #88888883; /* set thumb color */ 40 | } 41 | -------------------------------------------------------------------------------- /src/pages/debugTools/sessionCard.css: -------------------------------------------------------------------------------- 1 | .json-data-container { 2 | max-height: 300px; 3 | overflow-y: auto; 4 | background-color: black; 5 | border-radius: 0px 0px 8px 8px; 6 | } 7 | -------------------------------------------------------------------------------- /src/pages/fonts/Raleway-Italic-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/Raleway-Italic-VariableFont_wght.ttf -------------------------------------------------------------------------------- /src/pages/fonts/Raleway-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/Raleway-VariableFont_wght.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-Black.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-BlackItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-BlackItalic.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-Bold.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-BoldItalic.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-ExtraBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-ExtraBold.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-ExtraBoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-ExtraBoldItalic.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-ExtraLight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-ExtraLight.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-ExtraLightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-ExtraLightItalic.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-Italic.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-Light.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-LightItalic.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-Medium.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-MediumItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-MediumItalic.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-Regular.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-SemiBold.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-SemiBoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-SemiBoldItalic.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-Thin.ttf -------------------------------------------------------------------------------- /src/pages/fonts/static/Raleway-ThinItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/fonts/static/Raleway-ThinItalic.ttf -------------------------------------------------------------------------------- /src/pages/home.jsx: -------------------------------------------------------------------------------- 1 | import './css/home.css' 2 | 3 | import Sessions from './components/sessions/sessions' 4 | import HomeStatisticCards from './components/HomeStatisticCards' 5 | import LibraryOverView from './components/libraryOverview' 6 | import RecentlyAdded from './components/library/recently-added' 7 | import ErrorBoundary from './components/general/ErrorBoundary' 8 | 9 | export default function Home() { 10 | return ( 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | ) 25 | } -------------------------------------------------------------------------------- /src/pages/images/icon-b-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/images/icon-b-512.png -------------------------------------------------------------------------------- /src/pages/images/icon-w-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyferShepard/Jellystat/54185bb221465fac0ee91296aa79e55e72e60f71/src/pages/images/icon-w-512.png -------------------------------------------------------------------------------- /src/pages/testing.jsx: -------------------------------------------------------------------------------- 1 | import { Routes, Route } from "react-router-dom"; 2 | import Sessions from "./debugTools/sessions"; 3 | import PlayMethodStats from "./components/statistics/playbackMethodStats"; 4 | // import PlaybackMethodStats from "./components/statCards/playback_method_stats"; 5 | 6 | const TestingRoutes = () => { 7 | return ( 8 | 9 | } /> 10 | } /> 11 | {/* } /> */} 12 | 13 | ); 14 | }; 15 | 16 | export default TestingRoutes; 17 | -------------------------------------------------------------------------------- /src/routes.jsx: -------------------------------------------------------------------------------- 1 | import Home from "./pages/home"; 2 | 3 | import Settings from "./pages/settings"; 4 | import Users from "./pages/users"; 5 | import UserInfo from "./pages/components/user-info"; 6 | import Libraries from "./pages/libraries"; 7 | import LibraryInfo from "./pages/components/library-info"; 8 | import ItemInfo from "./pages/components/item-info"; 9 | import About from "./pages/about"; 10 | 11 | import TestingRoutes from "./pages/testing"; 12 | import Activity from "./pages/activity"; 13 | import Statistics from "./pages/statistics"; 14 | import ActivityTimeline from "./pages/activity_time_line"; 15 | 16 | const routes = [ 17 | { 18 | path: "/", 19 | element: , 20 | exact: true, 21 | }, 22 | { 23 | path: "/settings", 24 | element: , 25 | exact: true, 26 | }, 27 | { 28 | path: "/users", 29 | element: , 30 | exact: true, 31 | }, 32 | { 33 | path: "/users/:UserId", 34 | element: , 35 | exact: true, 36 | }, 37 | { 38 | path: "/libraries", 39 | element: , 40 | exact: true, 41 | }, 42 | { 43 | path: "/libraries/:LibraryId", 44 | element: , 45 | exact: true, 46 | }, 47 | { 48 | path: "/libraries/item/:Id", 49 | element: , 50 | exact: true, 51 | }, 52 | { 53 | path: "/statistics", 54 | element: , 55 | exact: true, 56 | }, 57 | { 58 | path: "/activity", 59 | element: , 60 | exact: true, 61 | }, 62 | { 63 | path: "/timeline", 64 | element: , 65 | exact: true, 66 | }, 67 | { 68 | path: "/about", 69 | element: , 70 | exact: true, 71 | }, 72 | { 73 | path: "/testing/*", 74 | element: , 75 | exact: true, 76 | }, 77 | ]; 78 | 79 | export default routes; 80 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/socket.js: -------------------------------------------------------------------------------- 1 | import { io } from "socket.io-client"; 2 | import baseUrl from "./lib/baseurl"; 3 | 4 | const socket = io({ 5 | path: baseUrl + "/socket.io/", 6 | }); 7 | 8 | export default socket; 9 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig, splitVendorChunkPlugin } from "vite"; 2 | import react from "@vitejs/plugin-react-swc"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | envPrefix: "JS_", 7 | base: "", 8 | optimizeDeps: { 9 | include: ["react", "react-dom", "react-router-dom", "axios", "react-toastify"], 10 | esbuildOptions: { 11 | loader: { 12 | ".js": "jsx", 13 | }, 14 | }, 15 | }, 16 | server: { 17 | // port for exposing frontend 18 | port: 3000, 19 | // port for exposing APIs 20 | proxy: { 21 | "/api": "http://127.0.0.1:3000", 22 | "/proxy": "http://127.0.0.1:3000", 23 | "/stats": "http://127.0.0.1:3000", 24 | "/sync": "http://127.0.0.1:3000", 25 | "/auth": "http://127.0.0.1:3000", 26 | "/backup": "http://127.0.0.1:3000", 27 | "/logs": "http://127.0.0.1:3000", 28 | "/socket.io": "http://127.0.0.1:3000", 29 | "/swagger": "http://127.0.0.1:3000", 30 | "/utils": "http://127.0.0.1:3000", 31 | }, 32 | }, 33 | target: ["es2015"], 34 | rollupOptions: { 35 | output: { 36 | manualChunks: { 37 | react: ["react"], 38 | "react-dom": ["react-dom"], 39 | "react-router-dom": ["react-router-dom"], 40 | axios: ["axios"], 41 | "react-toastify": ["react-toastify"], 42 | }, 43 | }, 44 | }, 45 | plugins: [react(), splitVendorChunkPlugin()], 46 | envDir: "backend", 47 | }); 48 | --------------------------------------------------------------------------------