├── .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 |{typeof message ==='object' ? message.Message : message}14 |