├── .claude └── settings.local.json ├── .cursor └── rules │ ├── github-pr-title.mdc │ ├── no-phoenix-server.mdc │ └── no-start-next-server.mdc ├── .github └── workflows │ ├── docker-edge.yml │ ├── docker-pr.yml │ ├── docker-release.yml │ └── release.yml ├── .gitignore ├── .release-please-manifest.json ├── OLLAMA_EMBEDDING_IMPLEMENTATION.md ├── README.md ├── RECOMMENDATION_FEATURES_SUMMARY.md ├── RECOMMENDATION_IMPROVEMENTS.md ├── app ├── .dockerignore ├── .env.example ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── README.md ├── app │ ├── (app) │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── servers │ │ │ └── [id] │ │ │ │ ├── (auth) │ │ │ │ ├── activities │ │ │ │ │ ├── ActivityLogTable.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── dashboard │ │ │ │ │ ├── ActiveSessions.tsx │ │ │ │ │ ├── BitrateDistributionCard.tsx │ │ │ │ │ ├── CodecUsageCard.tsx │ │ │ │ │ ├── ContainerFormatCard.tsx │ │ │ │ │ ├── DirectnessCard.tsx │ │ │ │ │ ├── Graph.tsx │ │ │ │ │ ├── HardwareAccelerationCard.tsx │ │ │ │ │ ├── LoadingSessions.tsx │ │ │ │ │ ├── MostWatchedDate.tsx │ │ │ │ │ ├── MostWatchedItems.tsx │ │ │ │ │ ├── NoStatsModal.tsx │ │ │ │ │ ├── Poster.tsx │ │ │ │ │ ├── ResolutionStatisticsCard.tsx │ │ │ │ │ ├── SimilarStatstics.tsx │ │ │ │ │ ├── TotalWatchTime.tsx │ │ │ │ │ ├── TranscodingReasonsCard.tsx │ │ │ │ │ ├── TranscodingStatistics.tsx │ │ │ │ │ ├── UserActivityChart.tsx │ │ │ │ │ ├── UserActivityWrapper.tsx │ │ │ │ │ ├── UserLeaderBoardFilter.tsx │ │ │ │ │ ├── UserLeaderBoardTable.tsx │ │ │ │ │ ├── UserLeaderboard.tsx │ │ │ │ │ ├── WatchTimeGraph.tsx │ │ │ │ │ ├── WatchTimePerHour.tsx │ │ │ │ │ ├── WatchTimePerWeekDay.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── transcoding │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── watchtime │ │ │ │ │ │ └── page.tsx │ │ │ │ ├── history │ │ │ │ │ ├── HistoryTable.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── items │ │ │ │ │ └── [itemId] │ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── library │ │ │ │ │ ├── ItemWatchStatsTable.tsx │ │ │ │ │ ├── LibraryDropdown.tsx │ │ │ │ │ ├── LibraryStatisticsCards.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── settings │ │ │ │ │ ├── DatabaseBackupRestore.tsx │ │ │ │ │ ├── DeleteServer.tsx │ │ │ │ │ ├── EmbeddingsManager.tsx │ │ │ │ │ ├── FullSyncTask.tsx │ │ │ │ │ ├── ImportTautulliData.tsx │ │ │ │ │ ├── JellystatsImport.tsx │ │ │ │ │ ├── LibrariesSyncTask.tsx │ │ │ │ │ ├── PlaybackReportingImport.tsx │ │ │ │ │ ├── Tasks.tsx │ │ │ │ │ ├── TautulliMappingModal.tsx │ │ │ │ │ ├── UsersSyncTask.tsx │ │ │ │ │ ├── VersionSection.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── users │ │ │ │ │ ├── UserTable.tsx │ │ │ │ │ ├── [name] │ │ │ │ │ ├── GenreStatsGraph.tsx │ │ │ │ │ ├── UserBadges.tsx │ │ │ │ │ ├── WatchTimePerDay.tsx │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── login │ │ │ │ ├── SignInForm.tsx │ │ │ │ └── page.tsx │ │ └── setup │ │ │ ├── SetupForm.tsx │ │ │ └── page.tsx │ ├── api │ │ ├── Sessions │ │ │ └── route.ts │ │ ├── check-connectivity │ │ │ └── route.ts │ │ ├── getItemHistory │ │ │ └── route.ts │ │ ├── servers │ │ │ └── [serverId] │ │ │ │ ├── backup │ │ │ │ └── route.ts │ │ │ │ ├── import │ │ │ │ └── route.ts │ │ │ │ └── items │ │ │ │ └── [itemId] │ │ │ │ └── route.ts │ │ ├── user-activity │ │ │ └── route.ts │ │ └── version │ │ │ └── route.ts │ ├── apple-icon.png │ ├── favicon.ico │ ├── fonts │ │ ├── GeistMonoVF.woff │ │ └── GeistVF.woff │ ├── globals.css │ ├── icon.png │ ├── icon.svg │ ├── layout.tsx │ └── not-found.tsx ├── components.json ├── components │ ├── Container.tsx │ ├── DynamicBreadcrumbs.tsx │ ├── ErrorBoundary.tsx │ ├── FadeInWrapper.tsx │ ├── ItemDetails.tsx │ ├── JellyfinAvatar.tsx │ ├── PageTitle.tsx │ ├── PlaybackMethodBadge.tsx │ ├── ServerConnectivityMonitor.tsx │ ├── ServerSelector.tsx │ ├── SideBar.tsx │ ├── Spinner.tsx │ ├── SuspenseLoading.tsx │ ├── UnwatchedTable.tsx │ ├── UpdateNotifier.tsx │ ├── UserMenu.tsx │ ├── VersionBadge.tsx │ ├── motion-primitives │ │ └── morphing-dialog.tsx │ └── ui │ │ ├── CustomBarLabel.tsx │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts ├── databases.json ├── hooks │ ├── use-mobile.tsx │ ├── useClickOutside.tsx │ ├── usePersistantState.tsx │ └── useQueryParams.tsx ├── lib │ ├── actions │ │ └── server-actions.ts │ ├── atoms │ │ ├── serverAtom.ts │ │ └── tokenAtom.ts │ ├── backupDatabase.ts │ ├── db.ts │ ├── db │ │ ├── server.ts │ │ ├── similar-statistics.ts │ │ └── transcoding-statistics.ts │ ├── importJellystats.ts │ ├── importPlaybackReporting.ts │ ├── me.ts │ ├── preferred-server.ts │ ├── timezone.ts │ ├── token.ts │ └── utils.ts ├── manifest.ts ├── middleware.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── providers │ └── jellyfin.tsx ├── public │ ├── .gitkeep │ ├── apple-touch-icon.png │ ├── favicon-96x96.png │ ├── favicon.ico │ ├── favicon.svg │ ├── site.webmanifest │ ├── web-app-manifest-192x192.png │ └── web-app-manifest-512x512.png ├── tailwind.config.ts └── tsconfig.json ├── biome.json ├── docker-compose.yml ├── release-please-config.json └── server ├── .dockerignore ├── .env.example ├── .formatter.exs ├── .tool-versions ├── CHANGELOG.md ├── Dockerfile ├── README.md ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── runtime.exs └── test.exs ├── entrypoint.sh ├── lib ├── streamystat_server.ex ├── streamystat_server │ ├── activities │ │ ├── activities.ex │ │ └── models │ │ │ └── activity.ex │ ├── admin_auth_plug.ex │ ├── application.ex │ ├── auth.ex │ ├── auth_plug.ex │ ├── batch_embedder.ex │ ├── contexts │ │ ├── active_sessions.ex │ │ ├── playback_sessions.ex │ │ └── users.ex │ ├── debug.ex │ ├── embedding_provider.ex │ ├── embedding_provider │ │ ├── ollama.ex │ │ └── open_ai.ex │ ├── embeddings.ex │ ├── http_client.ex │ ├── jellyfin │ │ ├── client.ex │ │ ├── libraries.ex │ │ ├── models │ │ │ ├── item.ex │ │ │ ├── library.ex │ │ │ └── user.ex │ │ ├── sync.ex │ │ ├── sync │ │ │ ├── activities.ex │ │ │ ├── items.ex │ │ │ ├── items │ │ │ │ ├── core.ex │ │ │ │ ├── image_refresh.ex │ │ │ │ ├── library_resolver.ex │ │ │ │ ├── mapper.ex │ │ │ │ └── recent.ex │ │ │ ├── libraries.ex │ │ │ ├── metrics.ex │ │ │ ├── users.ex │ │ │ └── utils.ex │ │ └── users.ex │ ├── mailer.ex │ ├── postgrex_types.ex │ ├── recommendations │ │ └── models │ │ │ └── hidden_recommendation.ex │ ├── release.ex │ ├── repo.ex │ ├── servers │ │ ├── models │ │ │ └── server.ex │ │ ├── servers.ex │ │ └── sync_log.ex │ ├── session_analysis.ex │ ├── sessions │ │ └── models │ │ │ └── playback_session.ex │ ├── statistics │ │ └── statistics.ex │ └── workers │ │ ├── auto_embedder.ex │ │ ├── jellystats_importer.ex │ │ ├── playback_reporting_importer.ex │ │ ├── session_poller.ex │ │ ├── sync_task.ex │ │ └── tautulli_importer.ex ├── streamystat_server_web.ex └── streamystat_server_web │ ├── controllers │ ├── active_sessions_controller.ex │ ├── active_sessions_json.ex │ ├── activity_controller.ex │ ├── activity_json.ex │ ├── auth_controller.ex │ ├── auth_json.ex │ ├── backup_controller.ex │ ├── base_controller.ex │ ├── changeset_json.ex │ ├── error_json.ex │ ├── health_controller.ex │ ├── jellystats_import_controller.ex │ ├── jellystats_import_json.ex │ ├── library_controller.ex │ ├── library_json.ex │ ├── playback_reporting_import_controller.ex │ ├── playback_reporting_import_json.ex │ ├── recommendation_controller.ex │ ├── recommendation_controller_json.ex │ ├── server_controller.ex │ ├── server_json.ex │ ├── statistics_controller.ex │ ├── statistics_json.ex │ ├── sync_controller.ex │ ├── sync_json.ex │ ├── tautulli_import_controller.ex │ ├── user_controller.ex │ ├── user_json.ex │ ├── user_statistics_controller.ex │ └── user_statistics_json.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── router.ex │ └── telemetry.ex ├── mix.exs ├── mix.lock ├── package-lock.json ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot └── repo │ ├── migrations │ ├── .formatter.exs │ ├── 20241028075909_create_servers.exs │ ├── 20241028075925_create_playback_stats.exs │ ├── 20241028082056_create_jellyfin_users.exs │ ├── 20241028082100_create_jellyfin_libraries.exs │ ├── 20241028082103_create_jellyfin_items.exs │ ├── 20241028112017_add_last_synced_playback_id_to_servers.exs │ ├── 20241028112755_create_playback_activities.exs │ ├── 20241028121954_add_item_type_and_name_to_playback_activities.exs │ ├── 20241028131532_add_admin_id_to_servers.exs │ ├── 20241029051101_create_sync_logs.exs │ ├── 20241029061718_add_user_reference_to_playback_activities.exs │ ├── 20241031081853_add_more_user_fields.exs │ ├── 20241031085005_add_server_info_fields.exs │ ├── 20241031090156_remove_admin_id_from_servers.exs │ ├── 20241101165920_add_fields_to_sync_logs.exs │ ├── 20241104174955_add_fields_to_jellyfin_items.exs │ ├── 20241105081134_update_last_activity_date_to_utc_datetime.exs │ ├── 20241105090408_alter_item_string_columns.exs │ ├── 20241105090700_add_series_fields_to_items.exs │ ├── 20241106204618_add_index_number_to_items.exs │ ├── 20241107075726_create_activities.exs │ ├── 20250330102238_create_playback_sessions.exs │ ├── 20250330121156_add_playback_tracking_fields.exs │ ├── 20250330122813_drop_legacy_playback_tables.exs │ ├── 20250330130544_alter_more_string_columns.exs │ ├── 20250331065023_convert_string_fields_to_text.exs │ ├── 20250401165856_add_image_fields_to_items.exs │ ├── 20250403065756_update_jellyfind_items_image_fields.exs │ ├── 20250403073228_add_more_image_fields_to_jellyfin_items.exs │ ├── 20250403084831_add_image_tags_to_items.exs │ ├── 20250404120602_add_statistics_indexes.exs │ ├── 20250420203410_remove_duplicate_playback_sessions.exs │ ├── 20250420203437_add_unique_index_to_playback_sessions.exs │ ├── 20250423074010_add_fields_to_playback_sessions.exs │ ├── 20250428183037_add_people_to_jellyfin_items.exs │ ├── 20250428183624_add_pgvector_extension.exs │ ├── 20250428183712_add_embedding_to_items.exs │ ├── 20250501072643_add_openai_token_to_servers.exs │ ├── 20250502190555_add_removed_at_to_jellyfin_items.exs │ ├── 20250504100042_add_removed_at_to_libraries.exs │ ├── 20250519081814_add_auto_generate_embeddings_to_servers.exs │ ├── 20250521082805_change_user_primary_key.exs │ ├── 20250521093328_change_item_primary_key_to_jellyfin_id.exs │ ├── 20250521093518_add_item_foreign_key_constraints.exs │ ├── 20250521094546_cleanup_after_jellyfin_id_migration.exs │ ├── 20250521095240_fix_activities_item_id_column.exs │ ├── 20250521102552_change_activities_primary_key_to_jellyfin_id.exs │ ├── 20250521104127_remove_user_server_id_from_activities.exs │ ├── 20250521132646_remove_playback_session_foreign_key_constraints.exs │ ├── 20250523041752_add_ollama_fields_to_servers.exs │ ├── 20250524154906_add_missing_sync_tracking_to_items.exs │ ├── 20250524164743_fix_hidden_recommendations_constraint.exs │ └── 999999999_add_hidden_recommendations.exs │ └── seeds.exs ├── rel └── overlays │ └── bin │ ├── migrate │ ├── migrate.bat │ ├── server │ └── server.bat └── test ├── streamystat_server_web └── controllers │ └── error_json_test.exs ├── support ├── conn_case.ex └── data_case.ex └── test_helper.exs /.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(git cherry-pick:*)", 5 | "Bash(ls:*)", 6 | "Bash(chmod:*)", 7 | "Bash(git add:*)", 8 | "Bash(git commit:*)", 9 | "Bash(touch:*)", 10 | "Bash(mkdir:*)" 11 | ], 12 | "deny": [] 13 | } 14 | } -------------------------------------------------------------------------------- /.cursor/rules/github-pr-title.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | Always use conventional commit messages when creating GitHub pull requests (PRs) or creating commit messages. Examples: feat:, fix:, chore:. -------------------------------------------------------------------------------- /.cursor/rules/no-phoenix-server.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | Don't run the phoenix server to troubleshoot. Assume the server is running elsewhere. Compiling, migrating or any other command that runs and terminates it self is fine. -------------------------------------------------------------------------------- /.cursor/rules/no-start-next-server.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | Don't ever start the nextjs server. You are free to build but not start or run dev server. -------------------------------------------------------------------------------- /.github/workflows/docker-edge.yml: -------------------------------------------------------------------------------- 1 | name: CI – push edge images 2 | on: 3 | push: 4 | branches: [main] 5 | 6 | env: 7 | REGISTRY: docker.io 8 | IMAGE_NAMESPACE: ${{ secrets.DOCKER_USERNAME }} 9 | 10 | jobs: 11 | edge-build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | part: [streamystats-nextjs, streamystats-phoenix] 16 | include: 17 | - part: streamystats-nextjs 18 | folder: app 19 | - part: streamystats-phoenix 20 | folder: server 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: docker/setup-qemu-action@v3 25 | - uses: docker/setup-buildx-action@v3 26 | - uses: docker/login-action@v3 27 | with: 28 | registry: ${{ env.REGISTRY }} 29 | username: ${{ secrets.DOCKER_USERNAME }} 30 | password: ${{ secrets.DOCKER_TOKEN }} 31 | 32 | - name: Build & push ${{ matrix.part }} 33 | uses: docker/build-push-action@v5 34 | with: 35 | context: ./${{ matrix.folder }} 36 | file: ./${{ matrix.folder }}/Dockerfile 37 | platforms: linux/amd64,linux/arm64 38 | push: true 39 | tags: ${{ env.IMAGE_NAMESPACE }}/${{ matrix.part }}:edge 40 | -------------------------------------------------------------------------------- /.github/workflows/docker-pr.yml: -------------------------------------------------------------------------------- 1 | name: CI – push PR images 2 | on: 3 | pull_request_target: 4 | types: [opened, synchronize, reopened] 5 | branches: [main] 6 | 7 | env: 8 | REGISTRY: docker.io 9 | IMAGE_NAMESPACE: ${{ secrets.DOCKER_USERNAME }} 10 | PR_TAG: pr-${{ github.event.pull_request.number }} 11 | 12 | jobs: 13 | pr-build: 14 | runs-on: ubuntu-latest 15 | if: github.event.pull_request.head.repo.full_name == github.repository # no secrets for forks 16 | strategy: 17 | matrix: 18 | part: [streamystats-nextjs, streamystats-phoenix] 19 | include: 20 | - part: streamystats-nextjs 21 | folder: app 22 | - part: streamystats-phoenix 23 | folder: server 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | ref: ${{ github.event.pull_request.head.sha }} 29 | 30 | - uses: docker/setup-qemu-action@v3 31 | - uses: docker/setup-buildx-action@v3 32 | 33 | - uses: docker/login-action@v3 34 | with: 35 | registry: ${{ env.REGISTRY }} 36 | username: ${{ secrets.DOCKER_USERNAME }} 37 | password: ${{ secrets.DOCKER_TOKEN }} 38 | 39 | - name: Build & push ${{ matrix.part }} 40 | uses: docker/build-push-action@v5 41 | with: 42 | context: ./${{ matrix.folder }} 43 | file: ./${{ matrix.folder }}/Dockerfile 44 | platforms: linux/amd64,linux/arm64 45 | push: true 46 | tags: | 47 | ${{ env.IMAGE_NAMESPACE }}/${{ matrix.part }}:${{ env.PR_TAG }} 48 | -------------------------------------------------------------------------------- /.github/workflows/docker-release.yml: -------------------------------------------------------------------------------- 1 | name: Release – version + edge images 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version: 6 | description: "Version tag" 7 | required: true 8 | repository_dispatch: 9 | types: [trigger-docker-build] 10 | release: 11 | types: [published] 12 | 13 | env: 14 | REGISTRY: docker.io 15 | IMAGE_NAMESPACE: ${{ secrets.DOCKER_USERNAME }} 16 | VERSION: ${{ github.event.inputs.version || github.event.client_payload.version || github.event.release.tag_name }} 17 | 18 | jobs: 19 | release-build: 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | part: [streamystats-nextjs, streamystats-phoenix] 24 | include: 25 | - part: streamystats-nextjs 26 | folder: app 27 | - part: streamystats-phoenix 28 | folder: server 29 | 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: docker/setup-qemu-action@v3 33 | - uses: docker/setup-buildx-action@v3 34 | - uses: docker/login-action@v3 35 | with: 36 | registry: ${{ env.REGISTRY }} 37 | username: ${{ secrets.DOCKER_USERNAME }} 38 | password: ${{ secrets.DOCKER_TOKEN }} 39 | 40 | - name: Build & push ${{ matrix.part }} 41 | uses: docker/build-push-action@v5 42 | with: 43 | context: ./${{ matrix.folder }} 44 | file: ./${{ matrix.folder }}/Dockerfile 45 | platforms: linux/amd64,linux/arm64 46 | push: true 47 | tags: | 48 | ${{ env.IMAGE_NAMESPACE }}/${{ matrix.part }}:${{ env.VERSION }} 49 | ${{ env.IMAGE_NAMESPACE }}/${{ matrix.part }}:edge 50 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | on: 3 | push: 4 | branches: [main] 5 | 6 | jobs: 7 | release: 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | issues: write 12 | actions: write 13 | runs-on: ubuntu-latest 14 | outputs: 15 | release_created: ${{ steps.release.outputs.release_created }} 16 | tag_name: ${{ steps.release.outputs.tag_name }} 17 | steps: 18 | - id: release 19 | uses: googleapis/release-please-action@v4 20 | with: 21 | config-file: release-please-config.json 22 | manifest-file: .release-please-manifest.json 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | default-branch: main 25 | include-v-in-tag: true 26 | command: manifest-pr 27 | 28 | # Create a GitHub release when a version tag is created 29 | - uses: actions/checkout@v4 30 | if: ${{ steps.release.outputs.release_created }} 31 | 32 | - name: Create GitHub Release 33 | if: ${{ steps.release.outputs.release_created }} 34 | uses: ncipollo/release-action@v1 35 | with: 36 | tag: ${{ steps.release.outputs.tag_name }} 37 | name: Release ${{ steps.release.outputs.tag_name }} 38 | generateReleaseNotes: true 39 | 40 | # Trigger Docker build workflow 41 | - name: Trigger Docker build workflow 42 | if: ${{ steps.release.outputs.release_created }} 43 | uses: peter-evans/repository-dispatch@v2 44 | with: 45 | token: ${{ secrets.GITHUB_TOKEN }} 46 | event-type: trigger-docker-build 47 | client-payload: '{"version": "${{ steps.release.outputs.tag_name }}"}' 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Root-level ignores 2 | node_modules/ 3 | *.env 4 | 5 | # Next.js (app/) 6 | app/.next/ 7 | app/node_modules/ 8 | app/.env.local 9 | app/.env.development 10 | app/.env.production 11 | app/out/ 12 | 13 | # Phoenix (server/) 14 | server/_build/ 15 | server/deps/ 16 | server/priv/static/ 17 | server/node_modules/ 18 | server/.env 19 | 20 | # General ignores 21 | .DS_Store 22 | *.log 23 | *.swp 24 | .idea/ 25 | .vscode/ 26 | coverage/ 27 | erl_crash.dump 28 | .claude/settings.local.json 29 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "1.8.0", 3 | "server": "1.8.0" 4 | } 5 | -------------------------------------------------------------------------------- /app/.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | README.md 6 | .next 7 | .git -------------------------------------------------------------------------------- /app/.env.example: -------------------------------------------------------------------------------- 1 | API_URL=http://localhost:4000/api -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts -------------------------------------------------------------------------------- /app/Dockerfile: -------------------------------------------------------------------------------- 1 | # Base image 2 | FROM node:23-alpine AS base 3 | 4 | # Install system dependencies 5 | RUN apk add --no-cache libc6-compat 6 | 7 | # Set working directory 8 | WORKDIR /app 9 | 10 | # Install dependencies 11 | COPY package.json package-lock.json* ./ 12 | RUN npm ci 13 | 14 | # Copy application code 15 | COPY . . 16 | 17 | # Set version 18 | ARG VERSION=edge 19 | ARG COMMIT_SHA 20 | ARG BUILD_TIME 21 | ENV NEXT_PUBLIC_VERSION=${VERSION} 22 | ENV NEXT_PUBLIC_COMMIT_SHA=${COMMIT_SHA} 23 | ENV NEXT_PUBLIC_BUILD_TIME=${BUILD_TIME} 24 | 25 | # Build the application 26 | RUN npm run build 27 | 28 | # Production image 29 | FROM node:23-alpine AS runner 30 | 31 | # Set working directory 32 | WORKDIR /app 33 | 34 | # Set environment variables 35 | ENV NODE_ENV=production 36 | ENV NEXT_SHARP_PATH=/app/node_modules/sharp 37 | ENV HOSTNAME=0.0.0.0 38 | 39 | # Install production dependencies 40 | COPY package.json package-lock.json* ./ 41 | RUN npm ci --omit=dev 42 | 43 | # Copy built application 44 | COPY --from=base /app/public ./public 45 | COPY --from=base /app/.next/standalone ./ 46 | COPY --from=base /app/.next/static ./.next/static 47 | 48 | # Expose port 49 | EXPOSE 3000 50 | 51 | # Start the application 52 | CMD ["node", "server.js"] 53 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /app/app/(app)/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { UpdateNotifier } from "@/components/UpdateNotifier"; 4 | import { VersionBadge } from "@/components/VersionBadge"; 5 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 6 | import { PropsWithChildren } from "react"; 7 | 8 | type Props = PropsWithChildren; 9 | 10 | const queryClient = new QueryClient(); 11 | 12 | export default function layout({ children }: Props) { 13 | return ( 14 | 15 | {children} 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/app/(app)/page.tsx: -------------------------------------------------------------------------------- 1 | import { getServers } from "@/lib/db"; 2 | import { getPreferredServer } from "@/lib/preferred-server"; 3 | import { redirect } from "next/navigation"; 4 | 5 | export default async function Home() { 6 | const servers = await getServers(); 7 | const preferredServerId = await getPreferredServer(); 8 | 9 | // If no servers exist, redirect to setup 10 | if (servers.length === 0) { 11 | redirect("/setup"); 12 | } 13 | 14 | // If we have a preferred server and it exists in the available servers, use it 15 | if (preferredServerId) { 16 | const preferredServer = servers.find( 17 | (server) => server.id === preferredServerId 18 | ); 19 | if (preferredServer) { 20 | redirect(`/servers/${preferredServer.id}/dashboard`); 21 | } 22 | } 23 | 24 | // Fallback to the first available server 25 | redirect(`/servers/${servers[0].id}/dashboard`); 26 | } 27 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/activities/page.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from "@/components/Container"; 2 | import { PageTitle } from "@/components/PageTitle"; 3 | import { Skeleton } from "@/components/ui/skeleton"; 4 | import { getActivities, getServer } from "@/lib/db"; 5 | import { redirect } from "next/navigation"; 6 | import { Suspense } from "react"; 7 | import { ActivityLogTable } from "./ActivityLogTable"; 8 | 9 | export default async function ActivitiesPage({ 10 | params, 11 | searchParams, 12 | }: { 13 | params: Promise<{ id: string }>; 14 | searchParams: Promise<{ page: string }>; 15 | }) { 16 | const { id } = await params; 17 | const { page } = await searchParams; 18 | 19 | const server = await getServer(id); 20 | 21 | if (!server) { 22 | redirect("/setup"); 23 | } 24 | 25 | const activities = await getActivities(server.id, page); 26 | 27 | return ( 28 | 29 | 30 | 33 | 34 | 35 | 36 | } 37 | > 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/dashboard/Graph.tsx: -------------------------------------------------------------------------------- 1 | import { Server, getWatchTimeGraph } from "@/lib/db"; 2 | import { Suspense } from "react"; 3 | import { WatchTimeGraph } from "./WatchTimeGraph"; 4 | 5 | interface Props { 6 | server: Server; 7 | startDate: string; 8 | endDate: string; 9 | } 10 | 11 | export async function Graph({ 12 | server, 13 | startDate, 14 | endDate, 15 | }: Props): Promise { 16 | const graphData = await getWatchTimeGraph(server.id, startDate, endDate); 17 | 18 | if (!graphData) { 19 | return

No data available

; 20 | } 21 | 22 | return ; 23 | } 24 | export default Graph; 25 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/dashboard/LoadingSessions.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Card, 5 | CardContent, 6 | CardDescription, 7 | CardHeader, 8 | CardTitle, 9 | } from "@/components/ui/card"; 10 | import { Skeleton } from "@/components/ui/skeleton"; 11 | import { MonitorPlay } from "lucide-react"; 12 | 13 | export default function LoadingSessions() { 14 | return ( 15 | 16 | 17 | 18 | 19 | Active Sessions 20 | 21 | 22 | Currently playing content on your server 23 | 24 | 25 | 26 |
27 | {[1, 2].map((i) => ( 28 |
29 | 30 | 31 | 32 |
33 | ))} 34 |
35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/dashboard/MostWatchedDate.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 2 | import { Statistics } from "@/lib/db"; 3 | import { formatDuration } from "@/lib/utils"; 4 | import { Calendar } from "lucide-react"; 5 | import React from "react"; 6 | 7 | const MostWatchedDate: React.FC<{ data: Statistics["most_watched_date"] }> = ({ 8 | data, 9 | }) => { 10 | if (!data) return null; 11 | 12 | const date = new Date(data.date); 13 | 14 | return ( 15 | 16 | 17 | 18 |

Most Active Day

19 |
20 | 21 |
22 | 23 |
24 |

{formatDate(date)}

25 |

26 | {formatDuration(data.total_duration)} watched 27 |

28 |
29 |
30 |
31 | ); 32 | }; 33 | 34 | function formatDate(date: Date): string { 35 | return date.toLocaleDateString("en-US", { 36 | year: "numeric", 37 | month: "long", 38 | day: "numeric", 39 | }); 40 | } 41 | 42 | export default MostWatchedDate; 43 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/dashboard/NoStatsModal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | AlertDialog, 5 | AlertDialogAction, 6 | AlertDialogContent, 7 | AlertDialogDescription, 8 | AlertDialogFooter, 9 | AlertDialogHeader, 10 | AlertDialogTitle, 11 | } from "@/components/ui/alert-dialog"; 12 | import { AlertDialogCancel } from "@radix-ui/react-alert-dialog"; 13 | import { useParams, useRouter } from "next/navigation"; 14 | import { useState } from "react"; 15 | 16 | export function NoStatsModal() { 17 | const router = useRouter(); 18 | const params = useParams(); 19 | const [open, setOpen] = useState(true); 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | Run inital full sync task to get started 27 | 28 | 29 | Run the full sync task from the settings page. Reload the page after 30 | ~1 minute to see your stats. 31 | 32 | 33 | 34 | { 36 | setOpen(false); 37 | }} 38 | className="mr-3" 39 | > 40 | Cancel 41 | 42 | { 44 | router.push(`/servers/${params.id}/settings`); 45 | }} 46 | > 47 | Continue to Settings 48 | 49 | 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/dashboard/TotalWatchTime.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 2 | import { formatDuration } from "@/lib/utils"; 3 | import { Clock } from "lucide-react"; 4 | import React from "react"; 5 | 6 | interface TotalWatchTimeProps { 7 | data: number; // Assuming data is in seconds 8 | } 9 | 10 | const TotalWatchTime: React.FC = ({ data }) => { 11 | return ( 12 | 13 | 14 | 15 |

Total Watch Time

16 |
17 | 18 |
19 | 20 |
21 |

{formatDuration(data)}

22 |

23 | Total time spent watching 24 |

25 |
26 |
27 |
28 | ); 29 | }; 30 | 31 | export default TotalWatchTime; 32 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/dashboard/UserActivityWrapper.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { UserActivityChart } from "./UserActivityChart"; 5 | import { Server, UserActivityPerDay } from "@/lib/db"; 6 | import { useSearchParams } from "next/navigation"; 7 | import { addDays } from "date-fns"; 8 | 9 | interface Props { 10 | server: Server; 11 | } 12 | 13 | export const UserActivityWrapper: React.FC = ({ server }) => { 14 | const searchParams = useSearchParams(); 15 | const [data, setData] = React.useState(null); 16 | const [loading, setLoading] = React.useState(false); 17 | 18 | // Get date parameters from URL 19 | const startDateParam = searchParams.get("userActivityStartDate"); 20 | const endDateParam = searchParams.get("userActivityEndDate"); 21 | 22 | // Calculate default dates 23 | const getDefaultStartDate = () => 24 | addDays(new Date(), -30).toISOString().split("T")[0]; 25 | const getDefaultEndDate = () => new Date().toISOString().split("T")[0]; 26 | 27 | const _startDate = startDateParam || getDefaultStartDate(); 28 | const _endDate = endDateParam || getDefaultEndDate(); 29 | 30 | // Fetch data when parameters change 31 | React.useEffect(() => { 32 | const fetchData = async () => { 33 | setLoading(true); 34 | try { 35 | const queryParams = new URLSearchParams({ 36 | serverId: server.id.toString(), 37 | startDate: _startDate, 38 | endDate: _endDate, 39 | }); 40 | 41 | const response = await fetch(`/api/user-activity?${queryParams}`); 42 | if (!response.ok) { 43 | throw new Error("Failed to fetch user activity data"); 44 | } 45 | 46 | const result = await response.json(); 47 | setData(result.data); 48 | } catch (error) { 49 | console.error("Error fetching user activity data:", error); 50 | setData(null); 51 | } finally { 52 | setLoading(false); 53 | } 54 | }; 55 | 56 | fetchData(); 57 | }, [server.id, _startDate, _endDate]); 58 | 59 | return ; 60 | }; 61 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/dashboard/UserLeaderboard.tsx: -------------------------------------------------------------------------------- 1 | import { Server, getUsers } from "@/lib/db"; 2 | import { UserLeaderboardTable } from "./UserLeaderBoardTable"; 3 | 4 | interface Props { 5 | server: Server; 6 | } 7 | 8 | export const UserLeaderboard = async ({ server }: Props) => { 9 | const users = await getUsers(server.id); 10 | 11 | return ; 12 | }; 13 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/dashboard/transcoding/page.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from "@/components/Container"; 2 | import { PageTitle } from "@/components/PageTitle"; 3 | import { Skeleton } from "@/components/ui/skeleton"; 4 | import { getServer, Server } from "@/lib/db"; 5 | import { getTranscodingStatistics } from "@/lib/db/transcoding-statistics"; 6 | import { redirect } from "next/navigation"; 7 | import { Suspense } from "react"; 8 | import { TranscodingStatistics } from "../TranscodingStatistics"; 9 | 10 | export default async function TranscodingPage({ 11 | params, 12 | }: { 13 | params: Promise<{ id: string }>; 14 | }) { 15 | const { id } = await params; 16 | const server = await getServer(id); 17 | 18 | if (!server) { 19 | redirect("/not-found"); 20 | } 21 | 22 | return ( 23 | 24 | 25 | }> 26 | 27 | 28 | 29 | ); 30 | } 31 | 32 | async function TranscodingStats({ server }: { server: Server }) { 33 | const ts = await getTranscodingStatistics(server.id); 34 | 35 | return ( 36 |
37 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/history/page.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from "@/components/Container"; 2 | import { PageTitle } from "@/components/PageTitle"; 3 | import { getServer, getStatisticsHistory } from "@/lib/db"; 4 | import { redirect } from "next/navigation"; 5 | import { HistoryTable } from "./HistoryTable"; 6 | 7 | export default async function HistoryPage({ 8 | params, 9 | searchParams, 10 | }: { 11 | params: Promise<{ id: string }>; 12 | searchParams: Promise<{ page?: string }>; 13 | }) { 14 | const { id } = await params; 15 | const { page } = await searchParams; 16 | const server = await getServer(id); 17 | 18 | if (!server) { 19 | redirect("/setup"); 20 | } 21 | 22 | const data = await getStatisticsHistory(server.id, page); 23 | return ( 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/items/[itemId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { ItemDetails } from "@/components/ItemDetails"; 2 | import { getItemStatistics } from "@/lib/db"; 3 | 4 | export default async function ItemDetailsPage({ 5 | params, 6 | }: { 7 | params: Promise<{ id: string; itemId: string }>; 8 | }) { 9 | const { id, itemId } = await params; 10 | 11 | const data = await getItemStatistics(id, itemId); 12 | 13 | if (!data) { 14 | return
Item not found
; 15 | } 16 | 17 | return ( 18 |
19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { DynamicBreadcrumbs } from "@/components/DynamicBreadcrumbs"; 4 | import ErrorBoundary from "@/components/ErrorBoundary"; 5 | import { FadeInWrapper } from "@/components/FadeInWrapper"; 6 | import { SideBar } from "@/components/SideBar"; 7 | import { SuspenseLoading } from "@/components/SuspenseLoading"; 8 | import { UpdateNotifier } from "@/components/UpdateNotifier"; 9 | import { Separator } from "@/components/ui/separator"; 10 | import { 11 | SidebarProvider, 12 | SidebarTrigger, 13 | SidebarInset, 14 | } from "@/components/ui/sidebar"; 15 | import { getServer, getServers } from "@/lib/db"; 16 | import { getMe, isUserAdmin } from "@/lib/me"; 17 | import { getPreferredServer } from "@/lib/preferred-server"; 18 | import { redirect } from "next/navigation"; 19 | import { PropsWithChildren, Suspense } from "react"; 20 | 21 | interface Props extends PropsWithChildren { 22 | params: Promise<{ id: string }>; 23 | } 24 | 25 | export default async function layout({ children, params }: Props) { 26 | const { id } = await params; 27 | 28 | const servers = await getServers(); 29 | const server = await getServer(id); 30 | const preferredServerId = await getPreferredServer(); 31 | 32 | const me = await getMe(); 33 | const isAdmin = await isUserAdmin(); 34 | 35 | if (!me) { 36 | redirect(`/servers/${id}/login`); 37 | } 38 | 39 | return ( 40 | 41 | 47 | }> 48 | 49 |
50 |
51 | 52 | 53 | 54 |
55 | {children} 56 |
57 |
58 |
59 | {isAdmin && } 60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/library/page.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from "@/components/Container"; 2 | import { PageTitle } from "@/components/PageTitle"; 3 | import { Skeleton } from "@/components/ui/skeleton"; 4 | import { 5 | getLibraries, 6 | getLibraryItems, 7 | getServer, 8 | getStatisticsLibrary 9 | } from "@/lib/db"; 10 | import { redirect } from "next/navigation"; 11 | import { Suspense } from "react"; 12 | import { ItemWatchStatsTable } from "./ItemWatchStatsTable"; 13 | import { LibraryStatisticsCards } from "./LibraryStatisticsCards"; 14 | import { getMe, isUserAdmin } from "@/lib/me"; 15 | 16 | export default async function DashboardPage({ 17 | params, 18 | searchParams, 19 | }: { 20 | params: Promise<{ id: string }>; 21 | searchParams: Promise<{ 22 | page: string; 23 | search: string; 24 | sort_by: string; 25 | type: "Movie" | "Episode" | "Series"; 26 | sort_order: string; 27 | libraries: string; 28 | }>; 29 | }) { 30 | const { id } = await params; 31 | const { 32 | page, 33 | search, 34 | sort_by, 35 | sort_order, 36 | type, 37 | libraries: libraryIds, 38 | } = await searchParams; 39 | 40 | const server = await getServer(id); 41 | const isAdmin = await isUserAdmin(); 42 | 43 | if (!server) { 44 | redirect("/not-found"); 45 | } 46 | 47 | const libraries = await getLibraries(server.id); 48 | const libraryStats = await getStatisticsLibrary(server.id); 49 | const items = await getLibraryItems( 50 | server.id, 51 | page, 52 | sort_order, 53 | sort_by, 54 | type, 55 | search, 56 | libraryIds 57 | ); 58 | 59 | return ( 60 | 61 | 65 | 66 | 69 | 70 | 71 | 72 | 73 | } 74 | > 75 | 80 | 81 | {/* */} 82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/settings/DeleteServer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Spinner } from "@/components/Spinner"; 4 | import { 5 | AlertDialog, 6 | AlertDialogAction, 7 | AlertDialogCancel, 8 | AlertDialogContent, 9 | AlertDialogDescription, 10 | AlertDialogFooter, 11 | AlertDialogHeader, 12 | AlertDialogTitle, 13 | AlertDialogTrigger, 14 | } from "@/components/ui/alert-dialog"; 15 | import { Button } from "@/components/ui/button"; 16 | import { Server, deleteServer } from "@/lib/db"; 17 | import { Loader2 } from "lucide-react"; 18 | import { useRouter } from "nextjs-toploader/app"; 19 | import { useState } from "react"; 20 | import { toast } from "sonner"; 21 | 22 | interface Props { 23 | server: Server; 24 | } 25 | 26 | export const DeleteServer: React.FC = ({ server }) => { 27 | const [loading, setLoading] = useState(false); 28 | const router = useRouter(); 29 | 30 | const handleDelete = async () => { 31 | setLoading(true); 32 | try { 33 | await deleteServer(server.id); 34 | router.push("/"); 35 | toast.success("Server deleted successfully"); 36 | } catch (error) { 37 | toast.error("Something went wrong"); 38 | console.error(error); 39 | } finally { 40 | setLoading(false); 41 | } 42 | }; 43 | 44 | return ( 45 |
46 |

Danger area

47 | 48 | 49 | 50 | 51 | 52 | Are you absolutely sure? 53 | 54 | 55 | This action cannot be undone. This will permanently delete the 56 | server and remove all associated data. 57 | 58 | 59 | 60 | Cancel 61 | 62 | {loading ? : "Delete"} 63 | 64 | 65 | 66 | 67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/settings/FullSyncTask.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Spinner } from "@/components/Spinner"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Skeleton } from "@/components/ui/skeleton"; 6 | import { Server, SyncTask, getSyncTasks, syncFullTask } from "@/lib/db"; 7 | import { isTaskRunning, taskLastRunAt } from "@/lib/utils"; 8 | import { useQuery } from "@tanstack/react-query"; 9 | import { useCallback, useMemo } from "react"; 10 | import { toast } from "sonner"; 11 | 12 | interface Props { 13 | server: Server; 14 | } 15 | 16 | export const FullSyncTask: React.FC = ({ server }) => { 17 | const { data, isLoading } = useQuery({ 18 | queryKey: ["tasks", server.id], 19 | queryFn: async () => { 20 | return await getSyncTasks(server.id); 21 | }, 22 | refetchInterval: 2000, 23 | staleTime: 2000, 24 | }); 25 | 26 | const running = useMemo( 27 | () => isTaskRunning(data, "full_sync") || false, 28 | [data], 29 | ); 30 | 31 | const lastRun = useMemo(() => taskLastRunAt(data, "full_sync"), [data]); 32 | 33 | const action = useCallback(async () => { 34 | try { 35 | await syncFullTask(server.id); 36 | toast.success("Task started"); 37 | } catch (error) { 38 | toast.error("Failed to start task"); 39 | } 40 | }, [server]); 41 | 42 | if (isLoading) return ; 43 | 44 | return ( 45 |
46 |
47 |

Full sync task

48 |

49 | Syncs all items, users, libraries and watch statistics from your 50 | Jellyfin server. 51 |

52 |

Last run: {lastRun}

53 |
54 | 62 |
63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/settings/ImportTautulliData.tsx: -------------------------------------------------------------------------------- 1 | export default async function ImportTautulliData() { 2 | return
; 3 | } 4 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/settings/LibrariesSyncTask.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Server, syncLibrariesTask, syncUsersTask } from "@/lib/db"; 5 | import { useCallback } from "react"; 6 | import { toast } from "sonner"; 7 | 8 | interface Props { 9 | server: Server; 10 | } 11 | 12 | export const LibrariesSyncTask: React.FC = ({ server }) => { 13 | const action = useCallback(async () => { 14 | try { 15 | await syncLibrariesTask(server.id); 16 | toast.success("Task started"); 17 | } catch (error) { 18 | toast.error("Failed to start task"); 19 | } 20 | }, [server]); 21 | 22 | return ( 23 |
24 |
25 |

Sync libraries

26 |

27 | Sync libraries from the server to the database. 28 |

29 |
30 | 38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/settings/Tasks.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Server } from "@/lib/db"; 4 | import { Separator } from "@radix-ui/react-separator"; 5 | import { DeleteServer } from "./DeleteServer"; 6 | import { FullSyncTask } from "./FullSyncTask"; 7 | import { LibrariesSyncTask } from "./LibrariesSyncTask"; 8 | import { UsersSyncTask } from "./UsersSyncTask"; 9 | 10 | interface TasksProps { 11 | server: Server; 12 | } 13 | 14 | export const Tasks: React.FC = ({ server }) => { 15 | return ( 16 |
17 | 18 | 19 | 20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/settings/UsersSyncTask.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Server, syncUsersTask } from "@/lib/db"; 5 | import { useCallback } from "react"; 6 | import { toast } from "sonner"; 7 | 8 | interface Props { 9 | server: Server; 10 | } 11 | 12 | export const UsersSyncTask: React.FC = ({ server }) => { 13 | const action = useCallback(async () => { 14 | try { 15 | await syncUsersTask(server.id); 16 | toast.success("Task started"); 17 | } catch (error) { 18 | toast.error("Failed to start task"); 19 | } 20 | }, [server]); 21 | 22 | return ( 23 |
24 |
25 |

Sync users

26 |

Sync users from the server to the database.

27 |
28 | 36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/settings/VersionSection.tsx: -------------------------------------------------------------------------------- 1 | export const VersionSection = () => { 2 | return ( 3 |
4 |

Version Information

5 |
6 |
7 |
8 |

Version

9 |

10 | {process.env.NEXT_PUBLIC_VERSION || "Not available"} 11 |

12 |
13 |
14 |

15 | Commit SHA 16 |

17 |

18 | {process.env.NEXT_PUBLIC_COMMIT_SHA?.substring(0, 7) || 19 | "Not available"} 20 |

21 |
22 |
23 |
24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { Container } from "@/components/Container"; 4 | import { getServer } from "@/lib/db"; 5 | import { redirect } from "next/navigation"; 6 | import { DeleteServer } from "./DeleteServer"; 7 | import JellystatsImport from "./JellystatsImport"; 8 | import { Tasks } from "./Tasks"; 9 | import { VersionSection } from "./VersionSection"; 10 | import PlaybackReportingImport from "./PlaybackReportingImport"; 11 | import { 12 | Accordion, 13 | AccordionContent, 14 | AccordionItem, 15 | AccordionTrigger, 16 | } from "@/components/ui/accordion"; 17 | import DatabaseBackupRestore from "./DatabaseBackupRestore"; 18 | import { EmbeddingsManager } from "./EmbeddingsManager"; 19 | import { 20 | Card, 21 | CardContent, 22 | CardDescription, 23 | CardHeader, 24 | CardTitle, 25 | } from "@/components/ui/card"; 26 | 27 | export default async function Settings({ 28 | params, 29 | searchParams, 30 | }: { 31 | params: Promise<{ id: string }>; 32 | searchParams: { section?: string }; 33 | }) { 34 | const { id } = await params; 35 | const server = await getServer(id); 36 | if (!server) { 37 | redirect("/setup"); 38 | } 39 | 40 | const section = searchParams.section || "general"; 41 | 42 | return ( 43 | 44 |

Settings

45 | 46 | {section === "general" && ( 47 |
48 | 49 | 50 |
51 | )} 52 | 53 | {section === "sync" && ( 54 |
55 | 56 |
57 | )} 58 | 59 | {section === "ai" && ( 60 |
61 | 62 |
63 | )} 64 | 65 | {section === "backup" && ( 66 |
67 |
68 | 69 |
70 | 71 |
72 | 73 |
74 | 75 |
76 | 77 |
78 |
79 | )} 80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/users/[name]/UserBadges.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Badge } from "@/components/ui/badge"; 4 | import { User } from "@/lib/db"; 5 | import React from "react"; 6 | 7 | interface UserBadgesProps { 8 | user: User; 9 | } 10 | 11 | const UserBadges: React.FC = ({ user }) => { 12 | return ( 13 |
14 | 15 | ID: {user.id} 16 | 17 | { 21 | navigator.clipboard 22 | .writeText(user.jellyfin_id || "") 23 | .then(() => { 24 | alert("Jellyfin ID copied to clipboard!"); 25 | }) 26 | .catch((err) => { 27 | console.error("Failed to copy: ", err); 28 | }); 29 | }} 30 | > 31 |

Jellyfin ID: {user.jellyfin_id}

32 |
33 |
34 | ); 35 | }; 36 | 37 | export default UserBadges; 38 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/(auth)/users/page.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from "@/components/Container"; 2 | import { PageTitle } from "@/components/PageTitle"; 3 | import { getServer, getUsers } from "@/lib/db"; 4 | import { redirect } from "next/navigation"; 5 | import { UserTable } from "./UserTable"; 6 | 7 | export default async function UsersPage({ 8 | params, 9 | }: { 10 | params: Promise<{ id: string }>; 11 | }) { 12 | const { id } = await params; 13 | const server = await getServer(id); 14 | 15 | if (!server) { 16 | redirect("/"); 17 | } 18 | 19 | const users = await getUsers(server.id); 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /app/app/(app)/servers/[id]/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { getServer, getServers } from "@/lib/db"; 2 | import { redirect } from "next/navigation"; 3 | import { SignInForm } from "./SignInForm"; 4 | 5 | export default async function Setup({ 6 | params, 7 | }: { 8 | params: Promise<{ id: string }>; 9 | }) { 10 | const { id } = await params; 11 | const server = await getServer(id); 12 | const servers = await getServers(); 13 | 14 | if (!server) { 15 | redirect("/not-found"); 16 | } 17 | 18 | return ; 19 | } 20 | -------------------------------------------------------------------------------- /app/app/(app)/setup/page.tsx: -------------------------------------------------------------------------------- 1 | import { SetupForm } from "./SetupForm"; 2 | 3 | export default async function Setup() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/app/api/getItemHistory/route.ts: -------------------------------------------------------------------------------- 1 | import { getItemStatistics } from "@/lib/db"; 2 | 3 | export const GET = async (req: Request) => { 4 | const { searchParams } = new URL(req.url); 5 | const serverId = searchParams.get("serverId"); 6 | const itemId = searchParams.get("itemId"); 7 | if (!serverId || !itemId) { 8 | return new Response("Missing serverId or itemId", { status: 400 }); 9 | } 10 | const data = await getItemStatistics(serverId, itemId); 11 | return Response.json(data); 12 | }; 13 | -------------------------------------------------------------------------------- /app/app/api/servers/[serverId]/backup/route.ts: -------------------------------------------------------------------------------- 1 | import { getToken } from "@/lib/token"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | 4 | export async function GET( 5 | req: NextRequest, 6 | { params }: { params: { serverId: string } } 7 | ) { 8 | const { serverId } = params; 9 | const token = await getToken(); 10 | if (!token) { 11 | return NextResponse.json( 12 | { error: "Not authenticated. Please log in again." }, 13 | { status: 401 } 14 | ); 15 | } 16 | 17 | const upstream = await fetch( 18 | `${process.env.API_URL}/servers/${serverId}/backup/export`, 19 | { 20 | headers: { Authorization: `Bearer ${token}` }, 21 | } 22 | ); 23 | 24 | if (!upstream.ok) { 25 | const err = await upstream.json().catch(() => ({ error: "Export failed" })); 26 | return NextResponse.json( 27 | { error: err.error || "Failed to export database" }, 28 | { status: upstream.status } 29 | ); 30 | } 31 | 32 | // mirror only content-type & content-disposition 33 | const headers = new Headers(); 34 | for (const [key, value] of upstream.headers) { 35 | if (/^content-(type|disposition)$/i.test(key)) { 36 | headers.set(key, value); 37 | } 38 | } 39 | 40 | return new NextResponse(upstream.body, { 41 | status: upstream.status, 42 | headers, 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /app/app/api/servers/[serverId]/import/route.ts: -------------------------------------------------------------------------------- 1 | import { getToken } from "@/lib/token"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | 4 | export async function POST( 5 | req: NextRequest, 6 | { params }: { params: { serverId: string } } 7 | ) { 8 | const { serverId } = params; 9 | const token = await getToken(); 10 | if (!token) { 11 | return NextResponse.json( 12 | { error: "Not authenticated. Please log in again." }, 13 | { status: 401 } 14 | ); 15 | } 16 | 17 | let formData: FormData; 18 | try { 19 | formData = await req.formData(); 20 | } catch { 21 | return NextResponse.json({ error: "Invalid form data" }, { status: 400 }); 22 | } 23 | 24 | const file = formData.get("file"); 25 | if (!(file instanceof File)) { 26 | return NextResponse.json({ error: "No file provided" }, { status: 400 }); 27 | } 28 | 29 | // Build multipart form for upstream 30 | const upstreamForm = new FormData(); 31 | // Convert File → Blob for Node fetch 32 | const buffer = await file.arrayBuffer(); 33 | upstreamForm.set("file", new Blob([buffer]), file.name); 34 | 35 | const upstreamRes = await fetch( 36 | `${process.env.API_URL}/servers/${serverId}/backup/import`, 37 | { 38 | method: "POST", 39 | headers: { Authorization: `Bearer ${token}` }, 40 | body: upstreamForm, 41 | } 42 | ); 43 | 44 | const payload = await upstreamRes 45 | .json() 46 | .catch(() => ({ error: "Unable to parse response" })); 47 | 48 | if (!upstreamRes.ok) { 49 | return NextResponse.json( 50 | { error: payload.error || "Import failed" }, 51 | { status: upstreamRes.status } 52 | ); 53 | } 54 | 55 | return NextResponse.json(payload, { status: upstreamRes.status }); 56 | } 57 | -------------------------------------------------------------------------------- /app/app/api/servers/[serverId]/items/[itemId]/route.ts: -------------------------------------------------------------------------------- 1 | import { getToken } from "@/lib/token"; 2 | import { NextRequest } from "next/server"; 3 | 4 | export const GET = async ( 5 | req: NextRequest, 6 | { params }: { params: { serverId: string; itemId: string } } 7 | ) => { 8 | const { serverId, itemId } = params; 9 | 10 | if (!serverId || !itemId) { 11 | return Response.json( 12 | { error: "Missing serverId or itemId" }, 13 | { status: 400 } 14 | ); 15 | } 16 | 17 | try { 18 | // Check for Authorization header from client request first 19 | let token = req.headers.get("Authorization"); 20 | 21 | // If no Authorization header is present, fallback to getToken() 22 | if (!token) { 23 | token = `Bearer ${await getToken()}`; 24 | } 25 | 26 | const res = await fetch( 27 | `${process.env.API_URL}/servers/${serverId}/statistics/items/${itemId}`, 28 | { 29 | cache: "no-store", 30 | headers: { 31 | Authorization: token, 32 | "Content-Type": "application/json", 33 | }, 34 | } 35 | ); 36 | 37 | if (!res.ok) { 38 | const errorText = await res.text(); 39 | if (res.status === 404) 40 | return Response.json({ error: "Item not found" }, { status: 404 }); 41 | if (res.status === 401) 42 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 43 | 44 | return Response.json( 45 | { error: `API error: ${errorText}` }, 46 | { status: res.status } 47 | ); 48 | } 49 | 50 | const data = await res.json(); 51 | 52 | if (!data || !data.data) { 53 | return Response.json({ error: "Item not found" }, { status: 404 }); 54 | } 55 | 56 | return Response.json(data.data); 57 | } catch (error) { 58 | console.error("Error fetching item data:", error); 59 | return Response.json({ error: "Internal server error" }, { status: 500 }); 60 | } 61 | }; 62 | -------------------------------------------------------------------------------- /app/app/api/user-activity/route.ts: -------------------------------------------------------------------------------- 1 | import { getUserActivityStatistics } from "@/lib/db"; 2 | import { NextRequest } from "next/server"; 3 | 4 | export async function GET(request: NextRequest) { 5 | const { searchParams } = new URL(request.url); 6 | const serverId = searchParams.get("serverId"); 7 | const startDate = searchParams.get("startDate"); 8 | const endDate = searchParams.get("endDate"); 9 | 10 | if (!serverId || !startDate || !endDate) { 11 | return Response.json( 12 | { error: "Missing required parameters: serverId, startDate, endDate" }, 13 | { status: 400 } 14 | ); 15 | } 16 | 17 | try { 18 | const data = await getUserActivityStatistics( 19 | parseInt(serverId), 20 | startDate, 21 | endDate 22 | ); 23 | 24 | return Response.json({ data }); 25 | } catch (error) { 26 | console.error("Error fetching user activity:", error); 27 | return Response.json( 28 | { error: "Failed to fetch user activity statistics" }, 29 | { status: 500 } 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/app/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/streamystats/63aadbc07139631f3d4c75280f920e0bdc4ab92e/app/app/apple-icon.png -------------------------------------------------------------------------------- /app/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/streamystats/63aadbc07139631f3d4c75280f920e0bdc4ab92e/app/app/favicon.ico -------------------------------------------------------------------------------- /app/app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/streamystats/63aadbc07139631f3d4c75280f920e0bdc4ab92e/app/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /app/app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/streamystats/63aadbc07139631f3d4c75280f920e0bdc4ab92e/app/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /app/app/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/streamystats/63aadbc07139631f3d4c75280f920e0bdc4ab92e/app/app/icon.png -------------------------------------------------------------------------------- /app/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import localFont from "next/font/local"; 3 | import "./globals.css"; 4 | import { Toaster } from "@/components/ui/sonner"; 5 | import NextTopLoader from "nextjs-toploader"; 6 | import { ServerConnectivityMonitor } from "@/components/ServerConnectivityMonitor"; 7 | 8 | const geistSans = localFont({ 9 | src: "./fonts/GeistVF.woff", 10 | variable: "--font-geist-sans", 11 | weight: "100 900", 12 | }); 13 | const geistMono = localFont({ 14 | src: "./fonts/GeistMonoVF.woff", 15 | variable: "--font-geist-mono", 16 | weight: "100 900", 17 | }); 18 | 19 | export const metadata: Metadata = { 20 | title: "Streamystats", 21 | description: 22 | "A statistics service for Jellyfin, providing analytics and data visualization. 📈", 23 | }; 24 | 25 | export default function RootLayout({ 26 | children, 27 | }: Readonly<{ 28 | children: React.ReactNode; 29 | }>) { 30 | return ( 31 | 32 | 35 | 36 | {children} 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import Link from "next/link"; 3 | 4 | export default function NotFound() { 5 | return ( 6 |
7 |

404

8 |

Oops! Page not found

9 |
10 |

11 | The page you're looking for doesn't exist or has been moved. 12 |

13 |
14 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/components/Container.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import React, { PropsWithChildren } from "react"; 3 | 4 | type Props = React.HTMLAttributes; 5 | 6 | export const Container: React.FC> = ({ 7 | children, 8 | className, 9 | ...props 10 | }) => { 11 | return ( 12 |
16 | {children} 17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /app/components/DynamicBreadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { House, Slash } from "lucide-react"; 4 | import { useParams, usePathname } from "next/navigation"; 5 | import React from "react"; 6 | import { 7 | Breadcrumb, 8 | BreadcrumbItem, 9 | BreadcrumbLink, 10 | BreadcrumbList, 11 | BreadcrumbSeparator, 12 | } from "./ui/breadcrumb"; 13 | 14 | const dynamicSegments = [ 15 | "users", 16 | "items", 17 | "library", 18 | "history", 19 | "dashboard", 20 | "settings", 21 | "activities", 22 | ]; 23 | 24 | export const DynamicBreadcrumbs: React.FC = () => { 25 | const params = useParams(); 26 | 27 | const { id } = params as { id: string }; 28 | 29 | const pathname = usePathname(); 30 | const pathSegments = pathname 31 | .split("/") 32 | .filter((segment) => segment) 33 | .slice(2); 34 | 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | {pathSegments.map((segment, index) => { 44 | const url = `/servers/${id}/${pathSegments 45 | .slice(0, index + 1) 46 | .join("/")}`; 47 | return ( 48 | 49 | 50 | 51 | 52 | {dynamicSegments.includes(segment) 53 | ? segment.charAt(0).toUpperCase() + segment.slice(1) 54 | : segment} 55 | 56 | 57 | 58 | ); 59 | })} 60 | 61 | 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /app/components/FadeInWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from "react"; 2 | 3 | export const FadeInWrapper: React.FC = ({ children }) => { 4 | return
{children}
; 5 | }; 6 | -------------------------------------------------------------------------------- /app/components/ItemDetails.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Item } from "@/lib/db"; 4 | 5 | interface WatchHistoryItem { 6 | id: number; 7 | user_id: number; 8 | user_name: string; 9 | jellyfin_user_id: string; 10 | start_time: string; 11 | play_duration: number; 12 | percent_complete: number; 13 | completed: boolean; 14 | client_name: string; 15 | device_name: string; 16 | } 17 | 18 | interface UserWatched { 19 | user_id: number; 20 | jellyfin_user_id: string; 21 | user_name: string; 22 | view_count: number; 23 | total_watch_time: number; 24 | last_watched: string; 25 | } 26 | 27 | interface MonthlyStats { 28 | month: string; 29 | view_count: number; 30 | total_watch_time: number; 31 | } 32 | 33 | export interface ItemStatistics { 34 | item: Item; 35 | total_views: number; 36 | total_watch_time: number; 37 | completion_rate: number; 38 | first_watched: string | null; 39 | last_watched: string | null; 40 | users_watched: UserWatched[]; 41 | watch_history: WatchHistoryItem[]; 42 | watch_count_by_month: MonthlyStats[]; 43 | } 44 | 45 | interface Props { 46 | item: Item; 47 | statistics: ItemStatistics; 48 | } 49 | 50 | export const ItemDetails: React.FC = ({ item, statistics }) => { 51 | return
{item.name}
; 52 | }; 53 | -------------------------------------------------------------------------------- /app/components/JellyfinAvatar.tsx: -------------------------------------------------------------------------------- 1 | import { User } from "@/lib/db"; 2 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 3 | import { cn } from "@/lib/utils"; 4 | import { useMemo } from "react"; 5 | 6 | interface Props { 7 | user: User | { id: string | number; name: string | null; jellyfin_id: string | null }; 8 | serverUrl?: string; 9 | imageTag?: string; 10 | quality?: number; 11 | className?: string; 12 | } 13 | 14 | export default function JellyfinAvatar({ 15 | user, 16 | serverUrl, 17 | imageTag, 18 | quality = 90, 19 | className, 20 | }: Props) { 21 | const imageUrl = useMemo(() => { 22 | if (!serverUrl || !user?.jellyfin_id) return null; 23 | 24 | return `${serverUrl}/Users/${user.jellyfin_id}/Images/Primary?quality=${quality}${ 25 | imageTag ? `&tag=${imageTag}` : "" 26 | }`; 27 | }, [serverUrl, user?.jellyfin_id, imageTag, quality]); 28 | 29 | const initials = useMemo(() => { 30 | if (!user?.name) return "?"; 31 | return user.name 32 | .split(" ") 33 | .map((n) => n[0]) 34 | .join("") 35 | .toUpperCase() 36 | .slice(0, 2); 37 | }, [user?.name]); 38 | 39 | if (!serverUrl || !user) return null; 40 | 41 | return ( 42 | 43 | 44 | {initials} 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /app/components/PageTitle.tsx: -------------------------------------------------------------------------------- 1 | import { AArrowDown, BarChart2 } from "lucide-react"; 2 | 3 | interface PageTitleProps { 4 | title: string; 5 | subtitle?: string; 6 | } 7 | 8 | export const PageTitle: React.FC = ({ title, subtitle }) => { 9 | return ( 10 |
11 |
12 | 13 |

{title}

14 |
15 | {subtitle && ( 16 |

17 | {subtitle} 18 |

19 | )} 20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /app/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from "lucide-react"; 2 | 3 | export const Spinner: React.FC = () => { 4 | return ; 5 | }; 6 | -------------------------------------------------------------------------------- /app/components/SuspenseLoading.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from "./Container"; 2 | import { FadeInWrapper } from "./FadeInWrapper"; 3 | import { Skeleton } from "./ui/skeleton"; 4 | 5 | export const SuspenseLoading: React.FC = () => { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /app/components/VersionBadge.tsx: -------------------------------------------------------------------------------- 1 | export const VersionBadge = () => { 2 | const sha = process.env.NEXT_PUBLIC_COMMIT_SHA?.substring(0, 7); 3 | return ( 4 |
5 | 6 | {process.env.NEXT_PUBLIC_VERSION ?? "edge"} 7 | {sha && ` (${sha})`} 8 | 9 |
10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /app/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDown } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Accordion = AccordionPrimitive.Root 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 20 | )) 21 | AccordionItem.displayName = "AccordionItem" 22 | 23 | const AccordionTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, children, ...props }, ref) => ( 27 | 28 | svg]:rotate-180", 32 | className 33 | )} 34 | {...props} 35 | > 36 | {children} 37 | 38 | 39 | 40 | )) 41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 42 | 43 | const AccordionContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, children, ...props }, ref) => ( 47 | 52 |
{children}
53 |
54 | )) 55 | 56 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 57 | 58 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 59 | -------------------------------------------------------------------------------- /app/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import { type VariantProps, cva } from "class-variance-authority"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | }, 20 | ); 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )); 33 | Alert.displayName = "Alert"; 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )); 45 | AlertTitle.displayName = "AlertTitle"; 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )); 57 | AlertDescription.displayName = "AlertDescription"; 58 | 59 | export { Alert, AlertTitle, AlertDescription }; 60 | -------------------------------------------------------------------------------- /app/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | Avatar.displayName = AvatarPrimitive.Root.displayName; 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )); 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )); 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 49 | 50 | export { Avatar, AvatarImage, AvatarFallback }; 51 | -------------------------------------------------------------------------------- /app/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import { type VariantProps, cva } from "class-variance-authority"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | }, 24 | ); 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ); 34 | } 35 | 36 | export { Badge, badgeVariants }; 37 | -------------------------------------------------------------------------------- /app/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /app/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )); 18 | Card.displayName = "Card"; 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )); 30 | CardHeader.displayName = "CardHeader"; 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )); 45 | CardTitle.displayName = "CardTitle"; 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )); 57 | CardDescription.displayName = "CardDescription"; 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )); 65 | CardContent.displayName = "CardContent"; 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )); 77 | CardFooter.displayName = "CardFooter"; 78 | 79 | export { 80 | Card, 81 | CardHeader, 82 | CardFooter, 83 | CardTitle, 84 | CardDescription, 85 | CardContent, 86 | }; 87 | -------------------------------------------------------------------------------- /app/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 4 | import { Check } from "lucide-react"; 5 | import * as React from "react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )); 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName; 29 | 30 | export { Checkbox }; 31 | -------------------------------------------------------------------------------- /app/components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Collapsible = CollapsiblePrimitive.Root; 9 | 10 | const CollapsibleTrigger = CollapsiblePrimitive.Trigger; 11 | 12 | const CollapsibleContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )); 22 | CollapsibleContent.displayName = "CollapsibleContent"; 23 | 24 | export { Collapsible, CollapsibleTrigger, CollapsibleContent }; 25 | -------------------------------------------------------------------------------- /app/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /app/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as LabelPrimitive from "@radix-ui/react-label"; 4 | import { type VariantProps, cva } from "class-variance-authority"; 5 | import * as React from "react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 11 | ); 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )); 24 | Label.displayName = LabelPrimitive.Root.displayName; 25 | 26 | export { Label }; 27 | -------------------------------------------------------------------------------- /app/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Popover = PopoverPrimitive.Root; 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger; 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )); 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 30 | 31 | export { Popover, PopoverTrigger, PopoverContent }; 32 | -------------------------------------------------------------------------------- /app/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as ProgressPrimitive from "@radix-ui/react-progress"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Progress = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, value, ...props }, ref) => ( 12 | 20 | 24 | 25 | )); 26 | Progress.displayName = ProgressPrimitive.Root.displayName; 27 | 28 | export { Progress }; 29 | -------------------------------------------------------------------------------- /app/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | )); 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, orientation = "vertical", ...props }, ref) => ( 30 | 43 | 44 | 45 | )); 46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; 47 | 48 | export { ScrollArea, ScrollBar }; 49 | -------------------------------------------------------------------------------- /app/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /app/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /app/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTheme } from "next-themes"; 4 | import { Toaster as Sonner } from "sonner"; 5 | 6 | type ToasterProps = React.ComponentProps; 7 | 8 | const Toaster = ({ ...props }: ToasterProps) => { 9 | const { theme = "system" } = useTheme(); 10 | 11 | return ( 12 | 28 | ); 29 | }; 30 | 31 | export { Toaster }; 32 | -------------------------------------------------------------------------------- /app/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )); 27 | Switch.displayName = SwitchPrimitives.Root.displayName; 28 | 29 | export { Switch }; 30 | -------------------------------------------------------------------------------- /app/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Tabs = TabsPrimitive.Root; 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )); 23 | TabsList.displayName = TabsPrimitive.List.displayName; 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )); 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )); 53 | TabsContent.displayName = TabsPrimitive.Content.displayName; 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent }; 56 | -------------------------------------------------------------------------------- /app/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /app/components/ui/use-toast.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from "react"; 2 | 3 | type ToastType = "default" | "destructive"; 4 | 5 | interface Toast { 6 | id: string; 7 | title: string; 8 | description?: string; 9 | type?: ToastType; 10 | } 11 | 12 | export function useToast() { 13 | const [toasts, setToasts] = useState([]); 14 | 15 | const addToast = useCallback( 16 | ({ title, description, type = "default" }: Omit) => { 17 | const id = Math.random().toString(36).substring(2, 9); 18 | setToasts((prev) => [...prev, { id, title, description, type }]); 19 | 20 | // Auto remove after 5 seconds 21 | setTimeout(() => { 22 | setToasts((prev) => prev.filter((t) => t.id !== id)); 23 | }, 5000); 24 | }, 25 | [] 26 | ); 27 | 28 | const success = useCallback( 29 | (description: string) => { 30 | addToast({ 31 | title: "Success", 32 | description, 33 | type: "default", 34 | }); 35 | }, 36 | [addToast] 37 | ); 38 | 39 | const error = useCallback( 40 | (description: string) => { 41 | addToast({ 42 | title: "Error", 43 | description, 44 | type: "destructive", 45 | }); 46 | }, 47 | [addToast] 48 | ); 49 | 50 | const dismiss = useCallback((id: string) => { 51 | setToasts((prev) => prev.filter((t) => t.id !== id)); 52 | }, []); 53 | 54 | return { success, error, dismiss, toasts }; 55 | } 56 | -------------------------------------------------------------------------------- /app/databases.json: -------------------------------------------------------------------------------- 1 | { 2 | "dev": { 3 | "driver": "pg", 4 | "connectionString": "postgresql://username:password@localhost:5432/your_database_name" 5 | }, 6 | "prod": { 7 | "driver": "pg", 8 | "connectionString": { "ENV": "DATABASE_URL" } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/hooks/use-mobile.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 12 | } 13 | mql.addEventListener("change", onChange) 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 15 | return () => mql.removeEventListener("change", onChange) 16 | }, []) 17 | 18 | return !!isMobile 19 | } 20 | -------------------------------------------------------------------------------- /app/hooks/useClickOutside.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect } from 'react'; 2 | 3 | function useClickOutside( 4 | ref: RefObject, 5 | handler: (event: MouseEvent | TouchEvent) => void 6 | ): void { 7 | useEffect(() => { 8 | const handleClickOutside = (event: MouseEvent | TouchEvent) => { 9 | if (!ref || !ref.current || ref.current.contains(event.target as Node)) { 10 | return; 11 | } 12 | 13 | handler(event); 14 | }; 15 | 16 | document.addEventListener('mousedown', handleClickOutside); 17 | document.addEventListener('touchstart', handleClickOutside); 18 | 19 | return () => { 20 | document.removeEventListener('mousedown', handleClickOutside); 21 | document.removeEventListener('touchstart', handleClickOutside); 22 | }; 23 | }, [ref, handler]); 24 | } 25 | 26 | export default useClickOutside; 27 | -------------------------------------------------------------------------------- /app/hooks/usePersistantState.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | export const usePersistantState = ( 6 | key: string, 7 | initialValue: T 8 | ): [T, React.Dispatch>, boolean] => { 9 | const [loading, setLoading] = useState(true); 10 | const [state, setState] = useState(initialValue); 11 | 12 | useEffect(() => { 13 | const storedValue = localStorage.getItem(key); 14 | if (storedValue) { 15 | try { 16 | setState(JSON.parse(storedValue)); 17 | } catch (e) { 18 | console.error(`Failed to parse stored value for key "${key}":`, e); 19 | } 20 | } 21 | setLoading(false); 22 | }, [key]); 23 | 24 | useEffect(() => { 25 | if (!loading) { 26 | localStorage.setItem(key, JSON.stringify(state)); 27 | } 28 | }, [key, state, loading]); 29 | 30 | return [state, setState, loading]; 31 | }; 32 | -------------------------------------------------------------------------------- /app/hooks/useQueryParams.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter, useSearchParams } from "next/navigation"; 2 | import { useEffect, useState, useTransition } from "react"; 3 | 4 | /** 5 | * Hook for managing query parameters in the URL with Suspense support 6 | */ 7 | export function useQueryParams() { 8 | const router = useRouter(); 9 | const searchParams = useSearchParams(); 10 | const [isPending, startTransition] = useTransition(); 11 | const [isLoading, setIsLoading] = useState(false); 12 | 13 | /** 14 | * Updates URL query parameters and triggers Suspense 15 | */ 16 | const updateQueryParams = ( 17 | params: Record, 18 | options: { scroll?: boolean } = { scroll: false }, 19 | ) => { 20 | setIsLoading(true); // Show loading state immediately 21 | 22 | // Start a transition to update the route 23 | startTransition(() => { 24 | const newSearchParams = new URLSearchParams(searchParams.toString()); 25 | 26 | // Update or remove each parameter 27 | Object.entries(params).forEach(([key, value]) => { 28 | if (value === null) { 29 | newSearchParams.delete(key); 30 | } else { 31 | newSearchParams.set(key, value); 32 | } 33 | }); 34 | 35 | router.replace(`?${newSearchParams.toString()}`, { 36 | scroll: options.scroll, 37 | }); 38 | }); 39 | }; 40 | 41 | // Reset loading state when the transition completes 42 | useEffect(() => { 43 | if (!isPending) { 44 | setIsLoading(false); 45 | } 46 | }, [isPending]); 47 | 48 | return { 49 | updateQueryParams, 50 | isLoading: isLoading || isPending, 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /app/lib/actions/server-actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { setPreferredServer as setPreferredServerCookie } from "@/lib/preferred-server"; 4 | 5 | export const setPreferredServerAction = async ( 6 | serverId: number 7 | ): Promise => { 8 | await setPreferredServerCookie(serverId); 9 | }; 10 | -------------------------------------------------------------------------------- /app/lib/atoms/serverAtom.ts: -------------------------------------------------------------------------------- 1 | import { atomWithStorage } from "jotai/utils"; 2 | import { Server } from "../db"; 3 | 4 | export const serverAtom = atomWithStorage( 5 | "selectedServer", 6 | null, 7 | ); 8 | -------------------------------------------------------------------------------- /app/lib/atoms/tokenAtom.ts: -------------------------------------------------------------------------------- 1 | import { atomWithStorage } from "jotai/utils"; 2 | import { Server } from "../db"; 3 | 4 | export const tokenAtom = atomWithStorage("token", null); 5 | -------------------------------------------------------------------------------- /app/lib/db/similar-statistics.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { Item } from "../db"; 4 | import { getToken } from "../token"; 5 | 6 | export interface RecommendationItem { 7 | item: Item; 8 | similarity: number; 9 | based_on: Item[]; 10 | } 11 | 12 | export const getSimilarStatistics = async ( 13 | serverId: number | string 14 | ): Promise => { 15 | console.log("Getting recommendations for server", serverId); 16 | try { 17 | const res = await fetch( 18 | `${process.env.API_URL}/servers/${serverId}/statistics/recommendations/me?limit=20`, 19 | { 20 | headers: { 21 | Authorization: `Bearer ${await getToken()}`, 22 | "Content-Type": "application/json", 23 | }, 24 | } 25 | ); 26 | 27 | if (!res.ok) { 28 | console.error( 29 | `Error fetching similar statistics: ${res.status} ${res.statusText}` 30 | ); 31 | return []; 32 | } 33 | 34 | const data = await res.json(); 35 | console.log("getSimilarStatistics ~", data); 36 | return data.data || []; 37 | } catch (error) { 38 | console.error("Error fetching similar statistics:", error); 39 | return []; 40 | } 41 | }; 42 | 43 | export const hideRecommendation = async ( 44 | serverId: number | string, 45 | itemId: string 46 | ): Promise<{ success: boolean; message?: string; error?: string }> => { 47 | try { 48 | const res = await fetch( 49 | `${process.env.API_URL}/servers/${serverId}/statistics/recommendations/hide/${itemId}`, 50 | { 51 | method: "POST", 52 | headers: { 53 | Authorization: `Bearer ${await getToken()}`, 54 | "Content-Type": "application/json", 55 | }, 56 | } 57 | ); 58 | 59 | const data = await res.json(); 60 | 61 | if (!res.ok) { 62 | return { 63 | success: false, 64 | error: 65 | data.error || 66 | `Failed to hide recommendation: ${res.status} ${res.statusText}`, 67 | }; 68 | } 69 | 70 | return data; 71 | } catch (error) { 72 | console.error("Error hiding recommendation:", error); 73 | return { 74 | success: false, 75 | error: "Network error while hiding recommendation", 76 | }; 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /app/lib/db/transcoding-statistics.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cookies, headers } from "next/headers"; 4 | import { getMe, UserMe } from "../me"; 5 | import { getToken } from "../token"; 6 | import { ItemStatistics } from "@/components/ItemDetails"; 7 | 8 | export interface CategoryStat { 9 | value: string | boolean | null; 10 | count: number; 11 | percentage: number; 12 | } 13 | 14 | // Type for numeric range distribution 15 | export interface RangeDistribution { 16 | range: string; 17 | min: number; 18 | max: number; 19 | count: number; 20 | } 21 | 22 | // Type for numeric field statistics 23 | export interface NumericStat { 24 | avg: number | null; 25 | min: number | null; 26 | max: number | null; 27 | count: number; 28 | distribution: RangeDistribution[]; 29 | } 30 | 31 | // Type for directness statistics 32 | export interface DirectnessStat { 33 | video_direct: boolean | null; 34 | audio_direct: boolean | null; 35 | label: string; 36 | count: number; 37 | percentage: number; 38 | } 39 | 40 | // Main type for the transcoding statistics response 41 | export interface TranscodingStatisticsResponse { 42 | // Categorical fields 43 | transcoding_audio_codec: CategoryStat[]; 44 | transcoding_video_codec: CategoryStat[]; 45 | transcoding_container: CategoryStat[]; 46 | transcoding_is_video_direct: CategoryStat[]; 47 | transcoding_is_audio_direct: CategoryStat[]; 48 | transcoding_hardware_acceleration_type: CategoryStat[]; 49 | 50 | // Numeric fields 51 | transcoding_bitrate: NumericStat; 52 | transcoding_completion_percentage: NumericStat; 53 | transcoding_width: NumericStat; 54 | transcoding_height: NumericStat; 55 | transcoding_audio_channels: NumericStat; 56 | 57 | // Array field 58 | transcoding_reasons: CategoryStat[]; 59 | 60 | // Combined statistics 61 | directness: DirectnessStat[]; 62 | } 63 | 64 | export const getTranscodingStatistics = async ( 65 | serverId: number | string 66 | ): Promise => { 67 | const res = await fetch( 68 | `${process.env.API_URL}/servers/${serverId}/statistics/transcoding`, 69 | { 70 | cache: "no-store", 71 | headers: { 72 | Authorization: `Bearer ${await getToken()}`, 73 | "Content-Type": "application/json", 74 | }, 75 | } 76 | ); 77 | if (!res.ok) { 78 | } 79 | const data = await res.json(); 80 | return data.data; 81 | }; 82 | -------------------------------------------------------------------------------- /app/lib/importJellystats.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { revalidatePath } from "next/cache"; 4 | import { getToken } from "./token"; 5 | 6 | type State = { 7 | type: "success" | "error" | "info" | null; 8 | message: string; 9 | }; 10 | 11 | export const importJellystats = async ( 12 | prevState: State, 13 | formData: FormData, 14 | ): Promise => { 15 | const file = formData.get("file") as File | null; 16 | const serverId = formData.get("serverId") as string; 17 | 18 | if (!file) { 19 | return { type: "error", message: "No file selected" }; 20 | } 21 | 22 | if (!serverId) { 23 | return { type: "error", message: "Server ID is missing" }; 24 | } 25 | 26 | try { 27 | // Validate file type 28 | if (file.type !== "application/json" && !file.name.endsWith(".json")) { 29 | return { 30 | type: "error", 31 | message: "Invalid file type. Please select a JSON file.", 32 | }; 33 | } 34 | 35 | // Prepare FormData for API request 36 | const apiFormData = new FormData(); 37 | apiFormData.append("file", file); 38 | 39 | // Make API request to backend 40 | const response = await fetch( 41 | `${process.env.API_URL}/admin/servers/${serverId}/jellystats/import`, 42 | { 43 | method: "POST", 44 | headers: { 45 | Authorization: `Bearer ${await getToken()}`, 46 | }, 47 | body: apiFormData, 48 | }, 49 | ); 50 | 51 | if (!response.ok) { 52 | const errorData = await response.json(); 53 | return { 54 | type: "error", 55 | message: 56 | errorData.error || 57 | "Failed to import Jellystats data. Please try again.", 58 | }; 59 | } 60 | 61 | // Successful response 62 | revalidatePath(`/servers/${serverId}/settings`); 63 | 64 | return { 65 | type: "success", 66 | message: 67 | "Your Jellystats data is being imported! This process runs in the background and may take several minutes depending on the amount of data.", 68 | }; 69 | } catch (error) { 70 | console.error("Error uploading Jellystats data:", error); 71 | return { 72 | type: "error", 73 | message: 74 | error instanceof Error 75 | ? error.message 76 | : "An unexpected error occurred. Please try again.", 77 | }; 78 | } 79 | }; 80 | -------------------------------------------------------------------------------- /app/lib/me.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cookies } from "next/headers"; 4 | import { User } from "./db"; 5 | import { getToken } from "./token"; 6 | 7 | export type UserMe = { 8 | id?: string; 9 | name?: string; 10 | serverId?: number; 11 | jellyfin_id?: string | null; 12 | watch_stats?: { total_watch_time: number; total_plays: number }; 13 | watch_time_per_day?: { date: string; total_duration: number }[]; 14 | is_administrator?: boolean; 15 | genre_stats?: { genre: string; count: number }[]; 16 | longest_streak?: number; 17 | watch_history?: { 18 | page: number; 19 | per_page: number; 20 | total_items: number; 21 | total_pages: number; 22 | data: any[]; 23 | }; 24 | }; 25 | 26 | export const getMe = async (): Promise => { 27 | const c = cookies(); 28 | const userStr = c.get("streamystats-user"); 29 | const user = userStr?.value ? JSON.parse(userStr.value) : undefined; 30 | 31 | return user ? (user as UserMe) : null; 32 | }; 33 | 34 | export const isUserAdmin = async (): Promise => { 35 | const me = await getMe(); 36 | 37 | if (!me) { 38 | return false; 39 | } 40 | 41 | try { 42 | const user: User = await fetch( 43 | `${process.env.API_URL}/servers/${me.serverId}/users/${me.id}`, 44 | { 45 | cache: "no-store", 46 | headers: { 47 | Authorization: `Bearer ${await getToken()}`, 48 | "Content-Type": "application/json", 49 | }, 50 | } 51 | ) 52 | .then((res) => res.json()) 53 | .then((res) => res.data); 54 | 55 | return user && user.is_administrator === true; 56 | } catch (e) { 57 | console.error("Failed to check if user is admin", e); 58 | return false; 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /app/lib/preferred-server.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cookies } from "next/headers"; 4 | 5 | const PREFERRED_SERVER_COOKIE = "streamystats-preferred-server"; 6 | const COOKIE_MAX_AGE = 365 * 24 * 60 * 60; // 1 year 7 | 8 | export const setPreferredServer = async (serverId: number): Promise => { 9 | const c = cookies(); 10 | c.set(PREFERRED_SERVER_COOKIE, serverId.toString(), { 11 | httpOnly: true, 12 | sameSite: "lax", 13 | path: "/", 14 | maxAge: COOKIE_MAX_AGE, 15 | secure: process.env.NODE_ENV === "production", 16 | }); 17 | }; 18 | 19 | export const getPreferredServer = async (): Promise => { 20 | const c = cookies(); 21 | const preferredServerCookie = c.get(PREFERRED_SERVER_COOKIE); 22 | 23 | if (preferredServerCookie?.value) { 24 | const serverId = parseInt(preferredServerCookie.value, 10); 25 | return isNaN(serverId) ? null : serverId; 26 | } 27 | 28 | return null; 29 | }; 30 | 31 | export const clearPreferredServer = async (): Promise => { 32 | const c = cookies(); 33 | c.delete(PREFERRED_SERVER_COOKIE); 34 | }; 35 | -------------------------------------------------------------------------------- /app/lib/token.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cookies } from "next/headers"; 4 | 5 | export const getToken = async (): Promise => { 6 | const c = cookies(); 7 | const token = c.get("streamystats-token"); 8 | return token?.value; 9 | }; 10 | -------------------------------------------------------------------------------- /app/manifest.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from "next"; 2 | 3 | export default function manifest(): MetadataRoute.Manifest { 4 | return { 5 | name: "Streamystats", 6 | short_name: "Streamystats", 7 | description: "A statistics service for Jellyfin.", 8 | start_url: "/", 9 | display: "standalone", 10 | background_color: "#000", 11 | theme_color: "#1C4ED8", 12 | icons: [ 13 | { 14 | src: "/web-app-manifest-192x192.png", 15 | sizes: "192x192", 16 | type: "image/png", 17 | }, 18 | { 19 | src: "/web-app-manifest-512x512.png", 20 | sizes: "512x512", 21 | type: "image/png", 22 | }, 23 | ], 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /app/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | output: "standalone", 4 | images: { 5 | remotePatterns: [ 6 | { 7 | protocol: "http", 8 | hostname: "*", 9 | }, 10 | { 11 | protocol: "https", 12 | hostname: "*", 13 | }, 14 | ], 15 | }, 16 | }; 17 | 18 | export default nextConfig; 19 | -------------------------------------------------------------------------------- /app/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /app/providers/jellyfin.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/streamystats/63aadbc07139631f3d4c75280f920e0bdc4ab92e/app/providers/jellyfin.tsx -------------------------------------------------------------------------------- /app/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/streamystats/63aadbc07139631f3d4c75280f920e0bdc4ab92e/app/public/.gitkeep -------------------------------------------------------------------------------- /app/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/streamystats/63aadbc07139631f3d4c75280f920e0bdc4ab92e/app/public/apple-touch-icon.png -------------------------------------------------------------------------------- /app/public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/streamystats/63aadbc07139631f3d4c75280f920e0bdc4ab92e/app/public/favicon-96x96.png -------------------------------------------------------------------------------- /app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/streamystats/63aadbc07139631f3d4c75280f920e0bdc4ab92e/app/public/favicon.ico -------------------------------------------------------------------------------- /app/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MyWebSite", 3 | "short_name": "MySite", 4 | "icons": [ 5 | { 6 | "src": "/web-app-manifest-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/web-app-manifest-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#1C4ED8", 19 | "background_color": "#000", 20 | "display": "standalone" 21 | } -------------------------------------------------------------------------------- /app/public/web-app-manifest-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/streamystats/63aadbc07139631f3d4c75280f920e0bdc4ab92e/app/public/web-app-manifest-192x192.png -------------------------------------------------------------------------------- /app/public/web-app-manifest-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/streamystats/63aadbc07139631f3d4c75280f920e0bdc4ab92e/app/public/web-app-manifest-512x512.png -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "noEmit": true, 6 | "moduleResolution": "nodenext", 7 | "resolveJsonModule": true, 8 | "isolatedModules": true, 9 | "jsx": "preserve", 10 | "incremental": true, 11 | "plugins": [ 12 | { 13 | "name": "next" 14 | } 15 | ], 16 | "paths": { 17 | "@/*": ["./*"] 18 | }, 19 | "target": "es6", 20 | "module": "NodeNext", 21 | "strict": true, 22 | "esModuleInterop": true, 23 | "skipLibCheck": true, 24 | "forceConsistentCasingInFileNames": true 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "files": { 7 | "ignore": [ 8 | "node_modules", 9 | "**/node_modules/**", 10 | ".husky", 11 | ".expo", 12 | ".github", 13 | ".vscode", 14 | "dist", 15 | "server" 16 | ] 17 | }, 18 | "linter": { 19 | "enabled": true, 20 | "rules": { 21 | "style": { 22 | "useImportType": "off", 23 | "noNonNullAssertion": "off" 24 | }, 25 | "recommended": true, 26 | "correctness": { "useExhaustiveDependencies": "off" }, 27 | "suspicious": { 28 | "noExplicitAny": "off", 29 | "noArrayIndexKey": "off" 30 | } 31 | } 32 | }, 33 | "formatter": { 34 | "enabled": true, 35 | "formatWithErrors": true, 36 | "attributePosition": "auto", 37 | "indentStyle": "space", 38 | "indentWidth": 2, 39 | "lineEnding": "lf", 40 | "lineWidth": 80 41 | }, 42 | "javascript": { 43 | "formatter": { 44 | "arrowParentheses": "always", 45 | "bracketSameLine": false, 46 | "bracketSpacing": true, 47 | "jsxQuoteStyle": "double", 48 | "quoteProperties": "asNeeded", 49 | "semicolons": "always", 50 | "lineWidth": 80 51 | } 52 | }, 53 | "json": { 54 | "formatter": { 55 | "trailingCommas": "none" 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | image: fredrikburmester/streamystats-nextjs:edge 4 | ports: 5 | - "3000:3000" 6 | depends_on: 7 | - phoenix 8 | networks: 9 | - backend 10 | environment: 11 | API_URL: "http://phoenix:4000/api" 12 | TZ: "Europe/Stockholm" 13 | 14 | phoenix: 15 | image: fredrikburmester/streamystats-phoenix:edge 16 | environment: 17 | DATABASE_URL: "ecto://postgres:postgres@db/streamystat" 18 | SECRET_KEY_BASE: "r0NgGxu5ETTDgF5G7mJsMevMbAF7y4w0IJlCRjzxHqSjbcrPqTQTzajOH0ne0sHH" 19 | ulimits: 20 | nofile: 21 | soft: 65536 22 | hard: 65536 23 | ports: 24 | - "4000:4000" 25 | depends_on: 26 | - db 27 | networks: 28 | - backend 29 | 30 | db: 31 | image: pgvector/pgvector:pg16 32 | environment: 33 | POSTGRES_USER: postgres 34 | POSTGRES_PASSWORD: postgres 35 | PGDATA: /var/lib/postgresql/data/pgdata 36 | restart: always 37 | networks: 38 | - backend 39 | volumes: 40 | - pgdata:/var/lib/postgresql/data 41 | 42 | volumes: 43 | pgdata: 44 | 45 | networks: 46 | backend: 47 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "release-type": "manifest", 3 | "include-v-in-tag": true, 4 | "packages": { 5 | "app": { "release-type": "node" }, 6 | "server":{ "release-type": "elixir" } 7 | } 8 | } -------------------------------------------------------------------------------- /server/.dockerignore: -------------------------------------------------------------------------------- 1 | # This file excludes paths from the Docker build context. 2 | # 3 | # By default, Docker's build context includes all files (and folders) in the 4 | # current directory. Even if a file isn't copied into the container it is still sent to 5 | # the Docker daemon. 6 | # 7 | # There are multiple reasons to exclude files from the build context: 8 | # 9 | # 1. Prevent nested folders from being copied into the container (ex: exclude 10 | # /assets/node_modules when copying /assets) 11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) 12 | # 3. Avoid sending files containing sensitive information 13 | # 14 | # More information on using .dockerignore is available here: 15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 16 | 17 | .dockerignore 18 | 19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed: 20 | # 21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat 22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc 23 | .git 24 | !.git/HEAD 25 | !.git/refs 26 | 27 | # Common development/test artifacts 28 | /cover/ 29 | /doc/ 30 | /test/ 31 | /tmp/ 32 | .elixir_ls 33 | 34 | # Mix artifacts 35 | /_build/ 36 | /deps/ 37 | *.ez 38 | 39 | # Generated on crash by the VM 40 | erl_crash.dump 41 | 42 | # Static artifacts - These should be fetched and built inside the Docker image 43 | /assets/node_modules/ 44 | /priv/static/assets/ 45 | /priv/static/cache_manifest.json 46 | -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_USERNAME=postgres 2 | DATABASE_PASSWORD=password 3 | DATABASE_HOSTNAME=localhost 4 | DATABASE_NAME=streamystat_server_dev 5 | DATABASE_PORT=5432 6 | DATABASE_POOL_SIZE=10 -------------------------------------------------------------------------------- /server/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /server/.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.17.3 global 2 | erlang 27.1.2 global 3 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # StreamystatServer 2 | 3 | To start your Phoenix server: 4 | 5 | * Run `mix setup` to install and setup dependencies 6 | * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` 7 | 8 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 9 | 10 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). 11 | 12 | ## Learn more 13 | 14 | * Official website: https://www.phoenixframework.org/ 15 | * Guides: https://hexdocs.pm/phoenix/overview.html 16 | * Docs: https://hexdocs.pm/phoenix 17 | * Forum: https://elixirforum.com/c/phoenix-forum 18 | * Source: https://github.com/phoenixframework/phoenix 19 | -------------------------------------------------------------------------------- /server/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :streamystat_server, 11 | ecto_repos: [StreamystatServer.Repo], 12 | generators: [timestamp_type: :utc_datetime] 13 | 14 | config :streamystat_server, StreamystatServer.Repo, types: StreamystatServer.PostgrexTypes 15 | 16 | # Configures the endpoint 17 | config :streamystat_server, StreamystatServerWeb.Endpoint, 18 | url: [host: "localhost"], 19 | adapter: Bandit.PhoenixAdapter, 20 | render_errors: [ 21 | formats: [json: StreamystatServerWeb.ErrorJSON], 22 | layout: false 23 | ], 24 | pubsub_server: StreamystatServer.PubSub, 25 | live_view: [signing_salt: "dCu6wPqY"] 26 | 27 | # Configures the mailer 28 | # 29 | # By default it uses the "Local" adapter which stores the emails 30 | # locally. You can see the emails in your browser, at "/dev/mailbox". 31 | # 32 | # For production it's recommended to configure a different adapter 33 | # at the `config/runtime.exs`. 34 | config :streamystat_server, StreamystatServer.Mailer, adapter: Swoosh.Adapters.Local 35 | 36 | # Configures Elixir's Logger 37 | config :logger, :console, 38 | format: "$time [$level] $message\n", 39 | metadata: [:request_id] 40 | 41 | # Use Jason for JSON parsing in Phoenix 42 | config :phoenix, :json_library, Jason 43 | 44 | config :streamystat_server, :embedding_provider, StreamystatServer.EmbeddingProvider.OpenAI 45 | 46 | config :logger, level: :debug 47 | 48 | # Import environment specific config. This must remain at the bottom 49 | # of this file so it overrides the configuration defined above. 50 | import_config "#{config_env()}.exs" 51 | -------------------------------------------------------------------------------- /server/config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configures Swoosh API Client 4 | config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: StreamystatServer.Finch 5 | 6 | # Disable Swoosh Local Memory Storage 7 | config :swoosh, local: false 8 | 9 | # Do not print debug messages in production 10 | config :logger, level: :info 11 | 12 | # Set Phoenix request logging to debug level 13 | config :streamystat_server, StreamystatServerWeb.Endpoint, log: :debug 14 | 15 | # Runtime production configuration, including reading 16 | # of environment variables, is done on config/runtime.exs. 17 | 18 | config :streamystat_server, StreamystatServer.Repo, 19 | adapter: Postgrex, 20 | types: StreamystatServer.PostgrexTypes 21 | -------------------------------------------------------------------------------- /server/config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :streamystat_server, StreamystatServer.Repo, 9 | username: "postgres", 10 | password: "postgres", 11 | hostname: "localhost", 12 | database: "streamystat_server_test#{System.get_env("MIX_TEST_PARTITION")}", 13 | pool: Ecto.Adapters.SQL.Sandbox, 14 | pool_size: System.schedulers_online() * 2 15 | 16 | # We don't run a server during test. If one is required, 17 | # you can enable the server option below. 18 | config :streamystat_server, StreamystatServerWeb.Endpoint, 19 | http: [ip: {127, 0, 0, 1}, port: 4002], 20 | secret_key_base: "ujkeywp/i9SWmWi0MnMwvoSQOpEnChQ2Lip4vRidr6n0eBX7oysfYJfokH+s3lWs", 21 | server: false 22 | 23 | # In test we don't send emails 24 | config :streamystat_server, StreamystatServer.Mailer, adapter: Swoosh.Adapters.Test 25 | 26 | # Disable swoosh api client as it is only required for production adapters 27 | config :swoosh, :api_client, false 28 | 29 | # Print only warnings and errors during test 30 | config :logger, level: :warning 31 | 32 | # Initialize plugs at runtime for faster test compilation 33 | config :phoenix, :plug_init_mode, :runtime 34 | -------------------------------------------------------------------------------- /server/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Parse DATABASE_URL to extract host, database name, user, and password 4 | DATABASE_URL=${DATABASE_URL} 5 | DB_HOST=$(echo $DATABASE_URL | sed -E 's/^.*@([^:/]+).*/\1/') 6 | DB_NAME=$(echo $DATABASE_URL | sed -E 's|^.*/([^/?]+).*|\1|') 7 | DB_USER=$(echo $DATABASE_URL | sed -E 's|^.*//([^:]+):.*|\1|') 8 | DB_PASSWORD=$(echo $DATABASE_URL | sed -E 's|^.*:([^@]+)@.*|\1|') 9 | 10 | # Wait for PostgreSQL to be ready 11 | until pg_isready -q -h $DB_HOST -p 5432 -U $DB_USER 12 | do 13 | echo "$(date) - waiting for database to start" 14 | sleep 2 15 | done 16 | 17 | # Check if the database exists, create it if it doesn't 18 | if ! PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -U $DB_USER -lqt | cut -d \| -f 1 | grep -qw $DB_NAME; then 19 | echo "Database $DB_NAME does not exist. Creating..." 20 | PGPASSWORD=$DB_PASSWORD createdb -h $DB_HOST -U $DB_USER $DB_NAME 21 | echo "Database $DB_NAME created." 22 | fi 23 | 24 | /server/bin/streamystat_server eval "StreamystatServer.Release.migrate" 25 | 26 | # Execute the server process as the main process (PID 1) 27 | exec /server/bin/streamystat_server start 28 | -------------------------------------------------------------------------------- /server/lib/streamystat_server.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer do 2 | @moduledoc """ 3 | StreamystatServer keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/activities/activities.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Activities.Activities do 2 | import Ecto.Query, warn: false 3 | alias StreamystatServer.Repo 4 | alias StreamystatServer.Activities.Models.Activity 5 | 6 | def list_activities(server, opts \\ []) do 7 | page = Keyword.get(opts, :page, 1) 8 | per_page = Keyword.get(opts, :per_page, 20) 9 | 10 | Activity 11 | |> where([a], a.server_id == ^server.id) 12 | |> order_by([a], desc: a.date) 13 | |> Repo.paginate(page: page, page_size: per_page) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/activities/models/activity.ex: -------------------------------------------------------------------------------- 1 | # lib/streamystat_server/jellyfin/activity.ex 2 | defmodule StreamystatServer.Activities.Models.Activity do 3 | use Ecto.Schema 4 | import Ecto.Changeset 5 | alias StreamystatServer.Jellyfin.Models.Item 6 | alias StreamystatServer.Jellyfin.Models.User 7 | alias StreamystatServer.Servers.Models.Server 8 | 9 | @primary_key {:jellyfin_id, :integer, []} 10 | schema "activities" do 11 | field(:name, :string) 12 | field(:short_overview, :string) 13 | field(:type, :string) 14 | field(:date, :utc_datetime) 15 | field(:severity, :string) 16 | field(:item_jellyfin_id, :string) 17 | 18 | field(:user_jellyfin_id, :string) 19 | belongs_to(:user, User, 20 | foreign_key: :user_jellyfin_id, 21 | references: :jellyfin_id, 22 | define_field: false) 23 | 24 | belongs_to(:item, Item, 25 | foreign_key: :item_jellyfin_id, 26 | references: :jellyfin_id, 27 | define_field: false) 28 | 29 | belongs_to(:server, Server, primary_key: true) 30 | 31 | timestamps() 32 | end 33 | 34 | def changeset(activity, attrs) do 35 | activity 36 | |> cast(attrs, [ 37 | :jellyfin_id, 38 | :name, 39 | :short_overview, 40 | :type, 41 | :date, 42 | :user_jellyfin_id, 43 | :server_id, 44 | :severity, 45 | :item_jellyfin_id 46 | ]) 47 | |> validate_required([:jellyfin_id, :server_id, :date]) 48 | |> unique_constraint([:jellyfin_id, :server_id], name: "activities_pkey") 49 | |> foreign_key_constraint(:server_id) 50 | |> foreign_key_constraint(:item_jellyfin_id) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/admin_auth_plug.ex: -------------------------------------------------------------------------------- 1 | # lib/streamystat_server_web/plugs/admin_auth_plug.ex 2 | defmodule StreamystatServerWeb.AdminAuthPlug do 3 | import Plug.Conn 4 | alias StreamystatServer.Auth 5 | alias StreamystatServer.Servers.Servers 6 | require Logger 7 | 8 | def init(opts), do: opts 9 | 10 | def call(conn, _opts) do 11 | auth_header = get_req_header(conn, "authorization") 12 | server_id = get_in(conn.path_params, ["server_id"]) || get_in(conn.path_params, ["id"]) 13 | 14 | # Then try to get the server 15 | server_result = 16 | case Servers.get_server(server_id) do 17 | nil -> {:error, :not_found} 18 | server -> {:ok, server} 19 | end 20 | 21 | with ["Bearer " <> token] <- auth_header, 22 | true <- is_binary(server_id), 23 | {:ok, server} <- server_result, 24 | {:ok, user_id} <- Auth.verify_token(server, token), 25 | {:ok, user_info} <- Auth.get_user_info(server, user_id), 26 | true <- is_admin?(user_info) do 27 | conn 28 | |> assign(:current_user_id, user_id) 29 | |> assign(:current_user, user_info) 30 | |> assign(:server, server) 31 | else 32 | false -> 33 | Logger.debug("User is not an admin") 34 | handle_unauthorized(conn) 35 | 36 | error -> 37 | Logger.debug("Authentication failed at step: #{inspect(error)}") 38 | handle_unauthorized(conn) 39 | end 40 | end 41 | 42 | defp handle_unauthorized(conn) do 43 | conn 44 | |> put_status(:unauthorized) 45 | |> Phoenix.Controller.put_view(json: StreamystatServerWeb.ErrorJSON) 46 | |> Phoenix.Controller.render(:error, message: "Unauthorized: Admin access required") 47 | |> halt() 48 | end 49 | 50 | defp is_admin?(user) do 51 | user["Policy"]["IsAdministrator"] == true 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/auth_plug.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.AuthPlug do 2 | import Plug.Conn 3 | alias StreamystatServer.Auth 4 | alias StreamystatServer.Servers.Servers 5 | require Logger 6 | 7 | def init(opts), do: opts 8 | 9 | def call(conn, _opts) do 10 | auth_header = get_req_header(conn, "authorization") 11 | server_id = get_in(conn.path_params, ["server_id"]) 12 | 13 | # Then try to get the server 14 | server_result = 15 | case Servers.get_server(server_id) do 16 | nil -> 17 | Logger.warning("Server not found for id: #{server_id}") 18 | {:error, :not_found} 19 | server -> 20 | Logger.debug("Found server: #{server.url}") 21 | {:ok, server} 22 | end 23 | 24 | with ["Bearer " <> token] <- auth_header, 25 | true <- is_binary(server_id), 26 | {:ok, server} <- server_result, 27 | {:ok, user_id} <- Auth.verify_token(server, token), 28 | {:ok, user_info} <- Auth.get_user_info(server, user_id) do 29 | conn 30 | |> assign(:current_user_id, user_id) 31 | |> assign(:current_user, user_info) 32 | |> assign(:server, server) 33 | else 34 | error -> 35 | Logger.warning("Authentication failed at step: #{inspect(error)}") 36 | handle_unauthorized(conn) 37 | end 38 | end 39 | 40 | defp handle_unauthorized(conn) do 41 | conn 42 | |> put_status(:unauthorized) 43 | |> Phoenix.Controller.put_view(json: StreamystatServerWeb.ErrorJSON) 44 | |> Phoenix.Controller.render(:error, message: "Invalid or missing token") 45 | |> halt() 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/embedding_provider.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.EmbeddingProvider do 2 | @callback embed(text :: String.t(), token :: String.t() | nil) :: {:ok, [float()]} | {:error, any()} 3 | @callback embed_batch(texts :: [String.t()], token :: String.t() | nil) :: {:ok, [[float()]]} | {:error, any()} 4 | end 5 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/embeddings.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Embeddings do 2 | alias StreamystatServer.EmbeddingProvider.OpenAI 3 | alias StreamystatServer.EmbeddingProvider.Ollama 4 | 5 | def embed(text, server_or_token \\ nil) do 6 | case get_provider_and_opts(server_or_token) do 7 | {provider, opts} -> provider.embed(text, opts) 8 | :error -> {:error, "No valid embedding configuration found"} 9 | end 10 | end 11 | 12 | def embed_batch(texts, server_or_token \\ nil) do 13 | case get_provider_and_opts(server_or_token) do 14 | {provider, opts} -> provider.embed_batch(texts, opts) 15 | :error -> {:error, "No valid embedding configuration found"} 16 | end 17 | end 18 | 19 | # Support for server struct 20 | defp get_provider_and_opts(%{embedding_provider: "ollama"} = server) do 21 | if server.ollama_base_url && server.ollama_model do 22 | {Ollama, server} 23 | else 24 | :error 25 | end 26 | end 27 | 28 | defp get_provider_and_opts(%{embedding_provider: "openai"} = server) do 29 | if server.open_ai_api_token do 30 | {OpenAI, server.open_ai_api_token} 31 | else 32 | :error 33 | end 34 | end 35 | 36 | defp get_provider_and_opts(%{open_ai_api_token: token} = server) when not is_nil(token) do 37 | # Backward compatibility for servers without embedding_provider field 38 | {OpenAI, token} 39 | end 40 | 41 | # Support for direct token (backward compatibility) 42 | defp get_provider_and_opts(token) when is_binary(token) do 43 | {OpenAI, token} 44 | end 45 | 46 | # Support for keyword list options 47 | defp get_provider_and_opts(opts) when is_list(opts) do 48 | provider = Keyword.get(opts, :provider, :openai) 49 | case provider do 50 | :openai -> {OpenAI, opts} 51 | :ollama -> {Ollama, opts} 52 | "openai" -> {OpenAI, opts} 53 | "ollama" -> {Ollama, opts} 54 | _ -> :error 55 | end 56 | end 57 | 58 | defp get_provider_and_opts(nil) do 59 | # Fallback to OpenAI with environment token 60 | if System.get_env("OPENAI_API_KEY") do 61 | {OpenAI, nil} 62 | else 63 | :error 64 | end 65 | end 66 | 67 | defp get_provider_and_opts(_), do: :error 68 | end 69 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/http_client.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.HttpClient do 2 | def post(url, body, headers) do 3 | HTTPoison.post(url, body, headers) 4 | end 5 | 6 | def get(url, headers) do 7 | HTTPoison.get(url, headers) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/jellyfin/libraries.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Jellyfin.Libraries do 2 | alias StreamystatServer.Jellyfin.Models.Library 3 | alias StreamystatServer.Repo 4 | alias StreamystatServer.Jellyfin.Sync.Utils 5 | import Ecto.Query 6 | 7 | @doc """ 8 | Gets all libraries for a server. 9 | """ 10 | def get_libraries(server_id) do 11 | from(l in Library, where: l.server_id == ^server_id) 12 | |> join(:inner, [l], active in subquery(Utils.get_libraries_by_server(server_id)), on: l.id == active.id) 13 | |> Repo.all() 14 | end 15 | 16 | @doc """ 17 | Gets a library by its ID and server ID. 18 | """ 19 | def get_library(id, server_id) do 20 | from(l in Library, where: l.id == ^id and l.server_id == ^server_id) 21 | |> join(:inner, [l], active in subquery(Utils.get_libraries_by_server(server_id)), on: l.id == active.id) 22 | |> Repo.one() 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/jellyfin/models/library.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Jellyfin.Models.Library do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "jellyfin_libraries" do 6 | field(:jellyfin_id, :string) 7 | field(:name, :string) 8 | field(:type, :string) 9 | field(:removed_at, :utc_datetime) 10 | belongs_to(:server, StreamystatServer.Jellyfin.Servers.Models.Server) 11 | 12 | timestamps() 13 | end 14 | 15 | def changeset(library, attrs) do 16 | library 17 | |> cast(attrs, [:jellyfin_id, :name, :type, :server_id, :removed_at]) 18 | |> validate_required([:jellyfin_id, :name, :type, :server_id]) 19 | |> unique_constraint([:jellyfin_id, :server_id]) 20 | |> foreign_key_constraint(:server_id) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/jellyfin/sync.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Jellyfin.Sync do 2 | @moduledoc """ 3 | Coordinates synchronization between Jellyfin servers and the local database. 4 | Delegates to specialized sync modules for specific entity types. 5 | """ 6 | 7 | alias StreamystatServer.Jellyfin.Sync.Users 8 | alias StreamystatServer.Jellyfin.Sync.Libraries 9 | alias StreamystatServer.Jellyfin.Sync.Items 10 | alias StreamystatServer.Jellyfin.Sync.Activities 11 | 12 | require Logger 13 | 14 | @sync_options %{ 15 | max_library_concurrency: 2, 16 | db_batch_size: 1000, 17 | api_request_delay_ms: 100, 18 | item_page_size: 500, 19 | max_retries: 3, 20 | retry_initial_delay_ms: 1000, 21 | adaptive_throttling: true 22 | } 23 | 24 | # Delegate to specialized modules 25 | defdelegate sync_users(server), to: Users 26 | defdelegate sync_libraries(server), to: Libraries 27 | defdelegate sync_items(server, options \\ %{}), to: Items 28 | defdelegate sync_recently_added_items(server, limit \\ 50), to: Items, as: :sync_recently_added 29 | defdelegate sync_activities(server, options \\ %{}), to: Activities 30 | defdelegate sync_recent_activities(server), to: Activities, as: :sync_recent 31 | 32 | @doc """ 33 | Returns the default synchronization options. 34 | These can be overridden by passing a map of options to the sync functions. 35 | """ 36 | def default_options, do: @sync_options 37 | end 38 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/jellyfin/sync/items.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Jellyfin.Sync.Items do 2 | @moduledoc """ 3 | Handles synchronization of Jellyfin media items to the local database. 4 | Delegates to specialized submodules for specific sync operations. 5 | """ 6 | 7 | alias StreamystatServer.Jellyfin.Sync.Items.Core 8 | alias StreamystatServer.Jellyfin.Sync.Items.Recent 9 | alias StreamystatServer.Jellyfin.Sync.Items.ImageRefresh 10 | 11 | # Public API - delegate to specialized modules 12 | defdelegate sync_items(server, user_options \\ %{}), to: Core 13 | defdelegate sync_recently_added(server, limit \\ 50), to: Recent 14 | defdelegate refresh_item_images(server, jellyfin_id), to: ImageRefresh 15 | defdelegate refresh_items_images(server, jellyfin_ids), to: ImageRefresh 16 | end 17 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/jellyfin/sync/metrics.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Jellyfin.Sync.Metrics do 2 | @moduledoc """ 3 | Handles metrics tracking for sync operations. 4 | """ 5 | 6 | require Logger 7 | 8 | @doc """ 9 | Creates a new metrics agent with the given initial metrics. 10 | """ 11 | def start_agent(initial_metrics \\ %{}) do 12 | Agent.start_link(fn -> initial_metrics end) 13 | end 14 | 15 | @doc """ 16 | Updates metrics in the metrics agent. 17 | If agent is nil, does nothing. 18 | """ 19 | def update(nil, _updates), do: :ok 20 | 21 | def update(agent, updates) do 22 | Agent.update(agent, fn metrics -> 23 | Map.merge(metrics, updates, fn _k, v1, v2 -> 24 | if is_integer(v1) and is_integer(v2) do 25 | v1 + v2 26 | else 27 | if is_list(v1) and is_list(v2) do 28 | v1 ++ v2 29 | else 30 | v2 31 | end 32 | end 33 | end) 34 | end) 35 | end 36 | 37 | @doc """ 38 | Gets current metrics from the agent. 39 | """ 40 | def get(agent) do 41 | Agent.get(agent, & &1) 42 | end 43 | 44 | @doc """ 45 | Stops the metrics agent. 46 | """ 47 | def stop(agent) do 48 | Agent.stop(agent) 49 | end 50 | 51 | @doc """ 52 | Logs summary metrics for a sync operation. 53 | """ 54 | def log_summary(server_name, operation_name, metrics, duration_ms) do 55 | metrics_str = 56 | metrics 57 | |> Map.drop([:start_time, :errors]) 58 | |> Enum.map(fn {k, v} -> "#{k}: #{v}" end) 59 | |> Enum.join("\n") 60 | 61 | Logger.debug(""" 62 | #{operation_name} completed for server #{server_name} 63 | Duration: #{duration_ms / 1000} seconds 64 | #{metrics_str} 65 | Errors: #{length(Map.get(metrics, :errors, []))} 66 | """) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Mailer do 2 | use Swoosh.Mailer, otp_app: :streamystat_server 3 | end 4 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/postgrex_types.ex: -------------------------------------------------------------------------------- 1 | Postgrex.Types.define( 2 | StreamystatServer.PostgrexTypes, 3 | Pgvector.extensions() ++ Ecto.Adapters.Postgres.extensions(), 4 | [] 5 | ) 6 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/recommendations/models/hidden_recommendation.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Recommendations.Models.HiddenRecommendation do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "hidden_recommendations" do 6 | field :user_jellyfin_id, :string 7 | field :item_jellyfin_id, :string 8 | field :hidden_at, :utc_datetime 9 | 10 | belongs_to :server, StreamystatServer.Servers.Models.Server 11 | 12 | timestamps() 13 | end 14 | 15 | @doc false 16 | def changeset(hidden_recommendation, attrs) do 17 | hidden_recommendation 18 | |> cast(attrs, [:user_jellyfin_id, :item_jellyfin_id, :server_id, :hidden_at]) 19 | |> validate_required([:user_jellyfin_id, :item_jellyfin_id, :server_id]) 20 | |> unique_constraint([:user_jellyfin_id, :item_jellyfin_id, :server_id], 21 | name: "hidden_recommendations_user_jellyfin_id_item_jellyfin_id_server") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/release.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Release do 2 | @moduledoc """ 3 | Used for executing DB release tasks when run in production without Mix 4 | installed. 5 | """ 6 | @app :streamystat_server 7 | 8 | def migrate do 9 | load_app() 10 | 11 | for repo <- repos() do 12 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 13 | end 14 | end 15 | 16 | def rollback(repo, version) do 17 | load_app() 18 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 19 | end 20 | 21 | defp repos do 22 | Application.fetch_env!(@app, :ecto_repos) 23 | end 24 | 25 | defp load_app do 26 | Application.load(@app) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo do 2 | use Ecto.Repo, 3 | otp_app: :streamystat_server, 4 | adapter: Ecto.Adapters.Postgres 5 | 6 | use Scrivener, page_size: 20 7 | end 8 | -------------------------------------------------------------------------------- /server/lib/streamystat_server/servers/sync_log.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Servers.SyncLog do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "sync_logs" do 6 | field(:sync_type, :string) 7 | field(:sync_started_at, :naive_datetime) 8 | field(:sync_completed_at, :naive_datetime) 9 | field(:status, :string) 10 | belongs_to(:server, StreamystatServer.Jellyfin.Models.User) 11 | 12 | timestamps() 13 | end 14 | 15 | @doc false 16 | def changeset(sync_log, attrs) do 17 | sync_log 18 | |> cast(attrs, [:server_id, :sync_type, :sync_started_at, :sync_completed_at, :status]) 19 | |> validate_required([:server_id, :sync_type, :sync_started_at]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, components, channels, and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use StreamystatServerWeb, :controller 9 | use StreamystatServerWeb, :html 10 | 11 | The definitions below will be executed for every controller, 12 | component, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define additional modules and import 17 | those modules here. 18 | """ 19 | 20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router, 25 | helpers: false, 26 | error_view: StreamystatServerWeb.ErrorJSON 27 | 28 | # Import common connection and controller functions to use in pipelines 29 | import Plug.Conn 30 | import Phoenix.Controller 31 | end 32 | end 33 | 34 | def channel do 35 | quote do 36 | use Phoenix.Channel 37 | end 38 | end 39 | 40 | def controller do 41 | quote do 42 | use Phoenix.Controller, 43 | formats: [:html, :json], 44 | layouts: [html: StreamystatServerWeb.Layouts] 45 | 46 | import Plug.Conn 47 | import StreamystatServerWeb.Gettext 48 | 49 | unquote(verified_routes()) 50 | end 51 | end 52 | 53 | def verified_routes do 54 | quote do 55 | use Phoenix.VerifiedRoutes, 56 | endpoint: StreamystatServerWeb.Endpoint, 57 | router: StreamystatServerWeb.Router, 58 | statics: StreamystatServerWeb.static_paths() 59 | end 60 | end 61 | 62 | @doc """ 63 | When used, dispatch to the appropriate controller/live_view/etc. 64 | """ 65 | defmacro __using__(which) when is_atom(which) do 66 | apply(__MODULE__, which, []) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/active_sessions_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.ActiveSessionsController do 2 | use StreamystatServerWeb, :controller 3 | alias StreamystatServer.Contexts.ActiveSessions 4 | require Logger 5 | 6 | def index(conn, %{"server_id" => server_id}) do 7 | current_user = conn.assigns.current_user 8 | 9 | # If the user is an admin, get all sessions 10 | # If not, only get sessions for this user 11 | sessions = 12 | if is_admin?(current_user) do 13 | ActiveSessions.list_active_sessions(server_id) 14 | else 15 | ActiveSessions.list_user_active_sessions(server_id, current_user["Id"]) 16 | end 17 | 18 | render(conn, :index, active_sessions: sessions) 19 | end 20 | 21 | defp is_admin?(user) do 22 | user["Policy"]["IsAdministrator"] == true 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/active_sessions_json.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.ActiveSessionsJSON do 2 | @doc """ 3 | Renders a list of active sessions. 4 | """ 5 | def index(%{active_sessions: active_sessions}) do 6 | %{data: for(session <- active_sessions, do: render_session(session))} 7 | end 8 | 9 | defp render_session(session) do 10 | %{ 11 | session_key: session.session_key, 12 | user: session.user, 13 | item: session.item, 14 | client: session.client, 15 | device_name: session.device_name, 16 | device_id: session.device_id, 17 | position_ticks: session.position_ticks, 18 | formatted_position: session.formatted_position, 19 | runtime_ticks: session.runtime_ticks, 20 | formatted_runtime: session.formatted_runtime, 21 | progress_percent: session.progress_percent, 22 | playback_duration: session.playback_duration, 23 | last_activity_date: format_datetime(session.last_activity_date), 24 | is_paused: session.is_paused, 25 | play_method: session.play_method 26 | } 27 | end 28 | 29 | defp format_datetime(nil), do: nil 30 | 31 | defp format_datetime(datetime) do 32 | datetime 33 | |> DateTime.truncate(:second) 34 | |> DateTime.to_iso8601() 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/activity_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.ActivityController do 2 | use StreamystatServerWeb, :controller 3 | alias StreamystatServer.Activities.Activities 4 | alias StreamystatServer.Servers.Servers 5 | 6 | def index(conn, %{"server_id" => server_id} = params) do 7 | case Servers.get_server(server_id) do 8 | nil -> 9 | conn 10 | |> put_status(:not_found) 11 | |> put_view(StreamystatServerWeb.ErrorJSON) 12 | |> render("404.json", %{}) 13 | 14 | server -> 15 | page = params["page"] || "1" 16 | per_page = params["per_page"] || "20" 17 | 18 | {page, _} = Integer.parse(page) 19 | {per_page, _} = Integer.parse(per_page) 20 | 21 | %{ 22 | entries: activities, 23 | page_number: page_number, 24 | page_size: page_size, 25 | total_entries: total_entries, 26 | total_pages: total_pages 27 | } = Activities.list_activities(server, page: page, per_page: per_page) 28 | 29 | render(conn, :index, 30 | activities: activities, 31 | page: page_number, 32 | per_page: page_size, 33 | total_items: total_entries, 34 | total_pages: total_pages 35 | ) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/activity_json.ex: -------------------------------------------------------------------------------- 1 | # lib/streamystat_server_web/controllers/activity_json.ex 2 | 3 | defmodule StreamystatServerWeb.ActivityJSON do 4 | alias StreamystatServer.Activities.Models.Activity 5 | alias StreamystatServer.Jellyfin.Models.User 6 | alias StreamystatServer.Repo 7 | 8 | def index(%{ 9 | activities: activities, 10 | page: page, 11 | per_page: per_page, 12 | total_items: total_items, 13 | total_pages: total_pages 14 | }) do 15 | %{ 16 | data: for(activity <- activities, do: data(activity)), 17 | page: page, 18 | per_page: per_page, 19 | total_items: total_items, 20 | total_pages: total_pages 21 | } 22 | end 23 | 24 | def data(%Activity{} = activity) do 25 | # Look up user directly by jellyfin_id and server_id 26 | user = if activity.user_jellyfin_id, 27 | do: User |> Repo.get_by([jellyfin_id: activity.user_jellyfin_id, server_id: activity.server_id]), 28 | else: nil 29 | 30 | %{ 31 | id: activity.jellyfin_id, 32 | jellyfin_id: activity.jellyfin_id, 33 | name: activity.name, 34 | short_overview: activity.short_overview, 35 | type: activity.type, 36 | date: activity.date, 37 | user_jellyfin_id: activity.user_jellyfin_id, 38 | user_name: user && user.name, 39 | server_id: activity.server_id, 40 | severity: activity.severity 41 | } 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/auth_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.AuthController do 2 | use StreamystatServerWeb, :controller 3 | alias StreamystatServer.Servers.Servers 4 | alias StreamystatServer.Auth 5 | 6 | def login(conn, %{"server_id" => server_id, "username" => username, "password" => password}) do 7 | case Servers.get_server(server_id) do 8 | nil -> 9 | conn 10 | |> put_status(:not_found) 11 | |> render(:error, message: "Server not found") 12 | 13 | server -> 14 | case Auth.authenticate_user(server, username, password) do 15 | {:ok, %{access_token: access_token, user: user}} -> 16 | conn 17 | |> put_status(:ok) 18 | |> render(:login, access_token: access_token, user: user) 19 | 20 | {:error, reason} -> 21 | conn 22 | |> put_status(:unauthorized) 23 | |> render(:error, message: reason) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/auth_json.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.AuthJSON do 2 | def login(%{access_token: access_token, user: user}) do 3 | %{access_token: access_token, user: user} 4 | end 5 | 6 | @spec error(%{:message => any(), optional(any()) => any()}) :: %{error: any()} 7 | def error(%{message: message}) do 8 | %{error: message} 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/base_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.BaseController do 2 | defmacro __using__(_) do 3 | quote do 4 | use StreamystatServerWeb, :controller 5 | action_fallback(StreamystatServerWeb.FallbackController) 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/changeset_json.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.ChangesetJSON do 2 | @doc """ 3 | Renders changeset errors. 4 | """ 5 | def error(%{changeset: changeset}) do 6 | # When encoded, the changeset returns its errors 7 | # as a JSON object. So we just pass it forward. 8 | %{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)} 9 | end 10 | 11 | defp translate_error({msg, opts}) do 12 | # You can make use of gettext to translate error messages by 13 | # uncommenting the following code: 14 | # if count = opts[:count] do 15 | # Gettext.dngettext(StreamystatServerWeb.Gettext, "errors", msg, msg, count, opts) 16 | # else 17 | # Gettext.dgettext(StreamystatServerWeb.Gettext, "errors", msg, opts) 18 | # end 19 | 20 | Enum.reduce(opts, msg, fn {key, value}, acc -> 21 | String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) 22 | end) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.ErrorJSON do 2 | # If you want to customize a particular status code, 3 | # you may add your own clauses, such as: 4 | # 5 | # def render("500.json", _assigns) do 6 | # %{errors: %{detail: "Internal Server Error"}} 7 | # end 8 | 9 | # By default, Phoenix returns the status message from 10 | # the template name. For example, "404.json" becomes 11 | # "Not Found". 12 | def render(template, _assigns) do 13 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/health_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.HealthController do 2 | use StreamystatServerWeb, :controller 3 | 4 | def check(conn, _params) do 5 | conn 6 | |> put_status(:ok) 7 | |> json(%{status: "ok"}) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/jellystats_import_json.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.JellystatsImportJSON do 2 | @moduledoc """ 3 | JSON view for JellystatsImportController responses. 4 | """ 5 | 6 | def import(%{message: message, status: status}) do 7 | %{ 8 | message: message, 9 | status: status 10 | } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/library_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.LibraryController do 2 | use StreamystatServerWeb, :controller 3 | alias StreamystatServer.Jellyfin.Libraries 4 | require Logger 5 | 6 | def index(conn, %{"server_id" => server_id}) do 7 | libraries = Libraries.get_libraries(server_id) 8 | render(conn, :index, libraries: libraries) 9 | end 10 | 11 | def show(conn, %{"server_id" => server_id, "id" => library_id}) do 12 | case Libraries.get_library(server_id, library_id) do 13 | nil -> 14 | conn 15 | |> put_status(:not_found) 16 | |> put_view(StreamystatServerWeb.ErrorJSON) 17 | |> render(:"404") 18 | 19 | library -> 20 | render(conn, :show, library: library) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/library_json.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.LibraryJSON do 2 | alias StreamystatServer.Jellyfin.Models.Library 3 | 4 | def index(%{libraries: libraries}) do 5 | %{data: for(library <- libraries, do: data(library))} 6 | end 7 | 8 | def show(%{library: library}) do 9 | %{data: data(library)} 10 | end 11 | 12 | defp data(%Library{} = library) do 13 | %{ 14 | id: library.id, 15 | jellyfin_id: library.jellyfin_id, 16 | name: library.name, 17 | type: library.type, 18 | server_id: library.server_id, 19 | inserted_at: library.inserted_at, 20 | updated_at: library.updated_at 21 | } 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/playback_reporting_import_json.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.PlaybackReportingImportJSON do 2 | @moduledoc """ 3 | JSON view for PlaybackReportingImportController responses. 4 | """ 5 | 6 | def import(%{message: message, status: status}) do 7 | %{ 8 | message: message, 9 | status: status 10 | } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/server_json.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.ServerJSON do 2 | alias StreamystatServer.Servers.Models.Server 3 | 4 | @doc """ 5 | Renders a single server. 6 | """ 7 | def show(%{server: server}) do 8 | %{data: data(server)} 9 | end 10 | 11 | @doc """ 12 | Renders a list of servers. 13 | """ 14 | def index(%{servers: servers}) do 15 | %{data: for(server <- servers, do: data(server))} 16 | end 17 | 18 | defp data(%Server{} = server) do 19 | %{ 20 | id: server.id, 21 | name: server.name, 22 | url: server.url, 23 | api_key: server.api_key, 24 | open_ai_api_token: server.open_ai_api_token, 25 | auto_generate_embeddings: server.auto_generate_embeddings, 26 | inserted_at: server.inserted_at, 27 | updated_at: server.updated_at, 28 | ollama_api_token: server.ollama_api_token, 29 | ollama_base_url: server.ollama_base_url, 30 | ollama_model: server.ollama_model, 31 | embedding_provider: server.embedding_provider 32 | } 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/statistics_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.StatisticsController do 2 | use StreamystatServerWeb, :controller 3 | alias StreamystatServer.Servers.Servers 4 | alias StreamystatServer.Statistics.Statistics 5 | require Logger 6 | 7 | def unwatched(conn, %{"server_id" => server_id} = params) do 8 | case Servers.get_server(server_id) do 9 | nil -> 10 | conn 11 | |> put_status(:not_found) 12 | |> put_view(StreamystatServerWeb.ErrorJSON) 13 | |> render("404.json", %{}) 14 | 15 | server -> 16 | page = params["page"] || "1" 17 | per_page = params["per_page"] || "20" 18 | type = params["type"] || "Movie" 19 | 20 | {page, _} = Integer.parse(page) 21 | {per_page, _} = Integer.parse(per_page) 22 | 23 | result = Statistics.get_unwatched_items(server.id, type, page, per_page) 24 | 25 | render(conn, :unwatched, 26 | items: result.items, 27 | page: result.page, 28 | per_page: result.per_page, 29 | total_items: result.total_items, 30 | total_pages: result.total_pages 31 | ) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/statistics_json.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.StatisticsJSON do 2 | def unwatched(%{ 3 | items: items, 4 | page: page, 5 | per_page: per_page, 6 | total_items: total_items, 7 | total_pages: total_pages 8 | }) do 9 | %{ 10 | data: for(item <- items, do: unwatched_item_data(item)), 11 | page: page, 12 | per_page: per_page, 13 | total_items: total_items, 14 | total_pages: total_pages 15 | } 16 | end 17 | 18 | defp unwatched_item_data(item) do 19 | %{ 20 | jellyfin_id: item.jellyfin_id, 21 | name: item.name, 22 | type: item.type, 23 | production_year: item.production_year, 24 | series_name: item.series_name, 25 | season_name: item.season_name, 26 | index_number: item.index_number, 27 | date_created: item.date_created, 28 | runtime_ticks: item.runtime_ticks 29 | } 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/sync_json.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.SyncJSON do 2 | alias StreamystatServer.Servers.SyncLog 3 | 4 | def tasks(%{tasks: tasks}) do 5 | %{data: for(task <- tasks, do: data(task))} 6 | end 7 | 8 | def task(%{task: task}) do 9 | %{data: data(task)} 10 | end 11 | 12 | defp data(%SyncLog{} = task) do 13 | data(Map.from_struct(task)) 14 | end 15 | 16 | defp data(%{} = task) do 17 | %{ 18 | id: task.id, 19 | server_id: Map.get(task, :server_id), 20 | sync_type: task.sync_type, 21 | sync_started_at: task.sync_started_at, 22 | sync_completed_at: task.sync_completed_at, 23 | status: task.status 24 | } 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/user_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.UserController do 2 | use StreamystatServerWeb, :controller 3 | alias StreamystatServer.Contexts.Users 4 | require Logger 5 | 6 | def index(conn, %{"server_id" => server_id}) do 7 | users = Users.get_users(server_id) 8 | 9 | users_with_details = 10 | Enum.map(users, fn user -> 11 | watch_stats = Users.get_user_watch_stats(server_id, user.jellyfin_id) 12 | %{user: user, watch_stats: watch_stats} 13 | end) 14 | 15 | render(conn, :index, users: users_with_details) 16 | end 17 | 18 | def show(conn, %{"server_id" => server_id, "id" => user_id} = params) do 19 | case Users.get_user(server_id, user_id) do 20 | nil -> 21 | conn 22 | |> put_status(:not_found) 23 | |> put_view(StreamystatServerWeb.ErrorJSON) 24 | |> render(:"404") 25 | 26 | user -> 27 | watch_stats = Users.get_user_watch_stats(server_id, user.jellyfin_id) 28 | watch_time_per_day = Users.get_user_watch_time_per_day(server_id, user.jellyfin_id) 29 | watch_time_per_weekday = Users.get_user_watch_time_per_weekday(server_id, user.jellyfin_id) 30 | genre_stats = Users.get_user_genre_watch_time(server_id, user.jellyfin_id) 31 | longest_streak = Users.get_user_longest_streak(user.jellyfin_id) 32 | watch_history = Users.get_user_watch_history(server_id, user.jellyfin_id, params) 33 | 34 | render(conn, :show, 35 | user: user, 36 | watch_stats: watch_stats, 37 | watch_time_per_day: watch_time_per_day, 38 | watch_time_per_weekday: watch_time_per_weekday, 39 | genre_stats: genre_stats, 40 | longest_streak: longest_streak, 41 | watch_history: watch_history 42 | ) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/controllers/user_json.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.UserJSON do 2 | alias StreamystatServer.Jellyfin.Models.User 3 | 4 | def index(%{users: users_with_details}) do 5 | %{data: for(user_data <- users_with_details, do: data(user_data, :index))} 6 | end 7 | 8 | def show(params) do 9 | %{data: data(params, :show)} 10 | end 11 | 12 | def me(%{user: user}) do 13 | %{ 14 | id: user["Id"], 15 | name: user["Name"], 16 | server_id: user["ServerId"], 17 | primary_image_tag: user["PrimaryImageTag"], 18 | has_password: user["HasPassword"], 19 | has_configured_password: user["HasConfiguredPassword"], 20 | last_login_date: user["LastLoginDate"], 21 | last_activity_date: user["LastActivityDate"], 22 | configuration: user["Configuration"], 23 | is_administrator: user["IsAdministrator"] 24 | } 25 | end 26 | 27 | defp data(params, view) do 28 | base_data(params) 29 | |> add_view_specific_data(params, view) 30 | end 31 | 32 | defp base_data(%{user: %User{} = user}) do 33 | %{ 34 | jellyfin_id: user.jellyfin_id, 35 | server_id: user.server_id, 36 | name: user.name, 37 | is_administrator: user.is_administrator 38 | } 39 | end 40 | 41 | defp add_view_specific_data(base, params, :index) do 42 | base 43 | |> Map.put(:watch_stats, watch_stats(params)) 44 | end 45 | 46 | defp add_view_specific_data(base, params, :show) do 47 | base 48 | |> Map.put(:watch_stats, watch_stats(params)) 49 | |> Map.put(:watch_history, params[:watch_history] || []) 50 | |> Map.put(:watch_time_per_day, params[:watch_time_per_day] || []) 51 | |> Map.put(:watch_time_per_weekday, params[:watch_time_per_weekday] || []) 52 | |> Map.put(:genre_stats, params[:genre_stats] || []) 53 | |> Map.put(:longest_streak, params[:longest_streak] || 0) 54 | end 55 | 56 | defp watch_stats(%{watch_stats: watch_stats}) do 57 | %{ 58 | total_watch_time: watch_stats.total_watch_time, 59 | total_plays: watch_stats.total_plays 60 | } 61 | end 62 | 63 | defp watch_stats(_), do: %{total_watch_time: 0, total_plays: 0} 64 | end 65 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :streamystat_server 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_streamystat_server_key", 10 | signing_salt: "89PnUTGe", 11 | same_site: "Lax" 12 | ] 13 | 14 | socket("/live", Phoenix.LiveView.Socket, 15 | websocket: [connect_info: [session: @session_options]], 16 | longpoll: [connect_info: [session: @session_options]] 17 | ) 18 | 19 | # Serve at "/" the static files from "priv/static" directory. 20 | # 21 | # You should set gzip to true if you are running phx.digest 22 | # when deploying your static files in production. 23 | plug(Plug.Static, 24 | at: "/", 25 | from: :streamystat_server, 26 | gzip: false, 27 | only: StreamystatServerWeb.static_paths() 28 | ) 29 | 30 | # Code reloading can be explicitly enabled under the 31 | # :code_reloader configuration of your endpoint. 32 | if code_reloading? do 33 | plug(Phoenix.CodeReloader) 34 | plug(Phoenix.Ecto.CheckRepoStatus, otp_app: :streamystat_server) 35 | end 36 | 37 | plug(Phoenix.LiveDashboard.RequestLogger, 38 | param_key: "request_logger", 39 | cookie_key: "request_logger" 40 | ) 41 | 42 | plug(Plug.RequestId) 43 | plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint]) 44 | 45 | plug(Plug.Parsers, 46 | parsers: [:urlencoded, :multipart, :json], 47 | pass: ["*/*"], 48 | json_decoder: Phoenix.json_library(), 49 | length: 1_073_741_824 50 | ) 51 | 52 | plug(Plug.MethodOverride) 53 | plug(Plug.Head) 54 | plug(Plug.Session, @session_options) 55 | plug(StreamystatServerWeb.Router) 56 | end 57 | -------------------------------------------------------------------------------- /server/lib/streamystat_server_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | use StreamystatServerWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext.Backend, otp_app: :streamystat_server 24 | end 25 | -------------------------------------------------------------------------------- /server/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :streamystat_server, 7 | version: "1.8.0", 8 | elixir: "~> 1.17.3", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps() 13 | ] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [ 21 | mod: {StreamystatServer.Application, []}, 22 | extra_applications: [:logger, :runtime_tools, :temp, :exqlite] 23 | ] 24 | end 25 | 26 | # Specifies which paths to compile per environment. 27 | defp elixirc_paths(:test), do: ["lib", "test/support"] 28 | defp elixirc_paths(_), do: ["lib"] 29 | 30 | # Specifies your project dependencies. 31 | # 32 | # Type `mix help deps` for examples and options. 33 | defp deps do 34 | [ 35 | {:mime, "~> 2.0"}, 36 | {:phoenix, "~> 1.7.14"}, 37 | {:phoenix_ecto, "~> 4.5"}, 38 | {:ecto_sql, "~> 3.10"}, 39 | {:phoenix_live_dashboard, "~> 0.8.3"}, 40 | {:swoosh, "~> 1.5"}, 41 | {:finch, "~> 0.13"}, 42 | {:telemetry_metrics, "~> 1.0"}, 43 | {:telemetry_poller, "~> 1.0"}, 44 | {:gettext, "~> 0.20"}, 45 | {:jason, "~> 1.2"}, 46 | {:dns_cluster, "~> 0.1.1"}, 47 | {:bandit, "~> 1.5"}, 48 | {:httpoison, "~> 2.0"}, 49 | {:scrivener_ecto, "~> 3.0.1"}, 50 | {:temp, "~> 0.4"}, 51 | {:exqlite, "~> 0.29"}, 52 | {:pgvector, "~> 0.3.0"}, 53 | {:postgrex, ">= 0.0.0"}, 54 | {:timex, "~> 3.7"}, 55 | ] 56 | end 57 | 58 | # Aliases are shortcuts or tasks specific to the current project. 59 | # For example, to install project dependencies and perform other setup tasks, run: 60 | # 61 | # $ mix setup 62 | # 63 | # See the documentation for `Mix` for more info on aliases. 64 | defp aliases do 65 | [ 66 | setup: ["deps.get", "ecto.setup"], 67 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 68 | "ecto.reset": ["ecto.drop", "ecto.setup"], 69 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] 70 | ] 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /server/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241028075909_create_servers.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.CreateServers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:servers) do 6 | add(:name, :string, null: false) 7 | add(:url, :string, null: false) 8 | add(:api_key, :string, null: false) 9 | 10 | timestamps() 11 | end 12 | 13 | create(unique_index(:servers, [:name])) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241028075925_create_playback_stats.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.CreatePlaybackStats do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:playback_stats) do 6 | add(:user_id, :string, null: false) 7 | add(:item_id, :string, null: false) 8 | add(:play_count, :integer, null: false) 9 | add(:total_duration, :integer, null: false) 10 | add(:date, :date, null: false) 11 | add(:server_id, references(:servers, on_delete: :delete_all), null: false) 12 | 13 | timestamps() 14 | end 15 | 16 | create(index(:playback_stats, [:server_id])) 17 | create(index(:playback_stats, [:date])) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241028082056_create_jellyfin_users.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.CreateJellyfinUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:jellyfin_users) do 6 | add(:jellyfin_id, :string, null: false) 7 | add(:name, :string, null: false) 8 | add(:server_id, references(:servers, on_delete: :delete_all), null: false) 9 | 10 | timestamps() 11 | end 12 | 13 | create(unique_index(:jellyfin_users, [:jellyfin_id, :server_id])) 14 | create(index(:jellyfin_users, [:server_id])) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241028082100_create_jellyfin_libraries.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.CreateJellyfinLibraries do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:jellyfin_libraries) do 6 | add(:jellyfin_id, :string, null: false) 7 | add(:name, :string, null: false) 8 | add(:type, :string, null: false) 9 | add(:server_id, references(:servers, on_delete: :delete_all), null: false) 10 | 11 | timestamps() 12 | end 13 | 14 | create(unique_index(:jellyfin_libraries, [:jellyfin_id, :server_id])) 15 | create(index(:jellyfin_libraries, [:server_id])) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241028082103_create_jellyfin_items.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.CreateJellyfinItems do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:jellyfin_items) do 6 | add(:jellyfin_id, :string, null: false) 7 | add(:name, :string, null: false) 8 | add(:type, :string, null: false) 9 | add(:library_id, references(:jellyfin_libraries, on_delete: :delete_all), null: false) 10 | add(:server_id, references(:servers, on_delete: :delete_all), null: false) 11 | timestamps() 12 | end 13 | 14 | create(unique_index(:jellyfin_items, [:jellyfin_id, :library_id])) 15 | create(unique_index(:jellyfin_items, [:jellyfin_id, :server_id])) 16 | 17 | create(index(:jellyfin_items, [:library_id])) 18 | create(index(:jellyfin_items, [:server_id])) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241028112017_add_last_synced_playback_id_to_servers.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddLastSyncedPlaybackIdToServers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:servers) do 6 | add(:last_synced_playback_id, :integer, default: 0) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241028112755_create_playback_activities.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.CreatePlaybackActivities do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:playback_activities) do 6 | add(:date_created, :naive_datetime, null: false) 7 | add(:server_id, references(:servers, on_delete: :delete_all), null: false) 8 | 9 | # Add other fields as needed. For example: 10 | add(:rowid, :integer) 11 | add(:item_id, :string) 12 | add(:user_id, :string) 13 | add(:client_name, :string) 14 | add(:device_name, :string) 15 | add(:device_id, :string) 16 | add(:play_method, :string) 17 | add(:play_duration, :integer) 18 | add(:play_count, :integer) 19 | 20 | timestamps() 21 | end 22 | 23 | # Add indexes as needed 24 | create(index(:playback_activities, [:server_id])) 25 | create(index(:playback_activities, [:date_created])) 26 | # You might want a unique constraint on rowid and server_id 27 | create(unique_index(:playback_activities, [:rowid, :server_id])) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241028121954_add_item_type_and_name_to_playback_activities.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddItemTypeAndNameToPlaybackActivities do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:playback_activities) do 6 | add(:item_type, :string) 7 | add(:item_name, :string) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241028131532_add_admin_id_to_servers.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddAdminIdToServers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:servers) do 6 | add(:admin_id, :string, null: false) 7 | end 8 | 9 | create(index(:servers, [:admin_id])) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241029051101_create_sync_logs.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.CreateSyncLogs do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:sync_logs) do 6 | add(:server_id, references(:servers, on_delete: :delete_all)) 7 | add(:sync_type, :string) 8 | add(:synced_at, :naive_datetime) 9 | 10 | timestamps() 11 | end 12 | 13 | create(index(:sync_logs, [:server_id])) 14 | create(index(:sync_logs, [:sync_type])) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241029061718_add_user_reference_to_playback_activities.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddUserReferenceToPlaybackActivities do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:playback_activities) do 6 | remove(:user_id) 7 | add(:user_id, references(:jellyfin_users, on_delete: :nilify_all)) 8 | end 9 | 10 | create(index(:playback_activities, [:user_id])) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241031081853_add_more_user_fields.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddMoreUserFields do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:jellyfin_users) do 6 | add(:has_password, :boolean) 7 | add(:has_configured_password, :boolean) 8 | add(:has_configured_easy_password, :boolean) 9 | add(:enable_auto_login, :boolean) 10 | add(:last_login_date, :naive_datetime) 11 | add(:last_activity_date, :naive_datetime) 12 | add(:is_administrator, :boolean) 13 | add(:is_hidden, :boolean) 14 | add(:is_disabled, :boolean) 15 | add(:enable_user_preference_access, :boolean) 16 | add(:enable_remote_control_of_other_users, :boolean) 17 | add(:enable_shared_device_control, :boolean) 18 | add(:enable_remote_access, :boolean) 19 | add(:enable_live_tv_management, :boolean) 20 | add(:enable_live_tv_access, :boolean) 21 | add(:enable_media_playback, :boolean) 22 | add(:enable_audio_playback_transcoding, :boolean) 23 | add(:enable_video_playback_transcoding, :boolean) 24 | add(:enable_playback_remuxing, :boolean) 25 | add(:enable_content_deletion, :boolean) 26 | add(:enable_content_downloading, :boolean) 27 | add(:enable_sync_transcoding, :boolean) 28 | add(:enable_media_conversion, :boolean) 29 | add(:enable_all_devices, :boolean) 30 | add(:enable_all_channels, :boolean) 31 | add(:enable_all_folders, :boolean) 32 | add(:enable_public_sharing, :boolean) 33 | add(:invalid_login_attempt_count, :integer) 34 | add(:login_attempts_before_lockout, :integer) 35 | add(:max_active_sessions, :integer) 36 | add(:remote_client_bitrate_limit, :integer) 37 | add(:authentication_provider_id, :string) 38 | add(:password_reset_provider_id, :string) 39 | add(:sync_play_access, :string) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241031085005_add_server_info_fields.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddServerInfoFields do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:servers) do 6 | add(:local_address, :string) 7 | add(:server_name, :string) 8 | add(:version, :string) 9 | add(:product_name, :string) 10 | add(:operating_system, :string) 11 | add(:jellyfin_id, :string) 12 | add(:startup_wizard_completed, :boolean, default: false) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241031090156_remove_admin_id_from_servers.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.RemoveAdminIdFromServers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:servers) do 6 | remove(:admin_id) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241101165920_add_fields_to_sync_logs.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddFieldsToSyncLogs do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:sync_logs) do 6 | add(:sync_started_at, :naive_datetime) 7 | add(:sync_completed_at, :naive_datetime) 8 | add(:status, :string) 9 | remove(:synced_at) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241104174955_add_fields_to_jellyfin_items.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddFieldsToJellyfinItems do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:jellyfin_items) do 6 | add(:original_title, :string) 7 | add(:etag, :string) 8 | add(:date_created, :utc_datetime) 9 | add(:container, :string) 10 | add(:sort_name, :string) 11 | add(:premiere_date, :utc_datetime) 12 | add(:external_urls, {:array, :map}) 13 | add(:path, :string) 14 | add(:official_rating, :string) 15 | add(:overview, :text) 16 | add(:genres, {:array, :string}) 17 | add(:community_rating, :float) 18 | add(:runtime_ticks, :bigint) 19 | add(:production_year, :integer) 20 | add(:is_folder, :boolean) 21 | add(:parent_id, :string) 22 | add(:media_type, :string) 23 | add(:width, :integer) 24 | add(:height, :integer) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241105081134_update_last_activity_date_to_utc_datetime.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.UpdateLastActivityDateToUtcDatetime do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:jellyfin_users) do 6 | modify(:last_activity_date, :utc_datetime) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241105090408_alter_item_string_columns.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AlterItemStringColumns do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:jellyfin_items) do 6 | modify(:name, :text) 7 | modify(:type, :text) 8 | modify(:original_title, :text) 9 | modify(:container, :text) 10 | modify(:sort_name, :text) 11 | modify(:path, :text) 12 | modify(:official_rating, :text) 13 | modify(:overview, :text) 14 | modify(:media_type, :text) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241105090700_add_series_fields_to_items.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddSeriesFieldsToItems do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:jellyfin_items) do 6 | add(:series_name, :text) 7 | add(:series_id, :text) 8 | add(:season_id, :text) 9 | add(:series_primary_image_tag, :text) 10 | add(:season_name, :text) 11 | add(:series_studio, :text) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241106204618_add_index_number_to_items.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddIndexNumberToItems do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:jellyfin_items) do 6 | add(:index_number, :integer) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20241107075726_create_activities.exs: -------------------------------------------------------------------------------- 1 | # Create a new migration file 2 | defmodule StreamystatServer.Repo.Migrations.CreateActivities do 3 | use Ecto.Migration 4 | 5 | def change do 6 | create table(:activities) do 7 | add :jellyfin_id, :integer, null: false 8 | add :name, :string 9 | add :short_overview, :text 10 | add :type, :string 11 | add :date, :utc_datetime 12 | add :user_id, references(:jellyfin_users, on_delete: :nilify_all) 13 | add :server_id, references(:servers, on_delete: :delete_all) 14 | add :severity, :string 15 | 16 | timestamps() 17 | end 18 | 19 | create index(:activities, [:jellyfin_id, :server_id], unique: true) 20 | create index(:activities, [:user_id]) 21 | create index(:activities, [:server_id]) 22 | create index(:activities, [:date]) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250330102238_create_playback_sessions.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.CreatePlaybackSessions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:playback_sessions) do 6 | add(:user_jellyfin_id, :string, null: false) 7 | add(:device_id, :string) 8 | add(:device_name, :string) 9 | add(:client_name, :string) 10 | add(:item_jellyfin_id, :string, null: false) 11 | add(:item_name, :string) 12 | add(:series_jellyfin_id, :string) 13 | add(:series_name, :string) 14 | add(:season_jellyfin_id, :string) 15 | add(:play_duration, :integer, null: false) 16 | add(:play_method, :string) 17 | add(:start_time, :utc_datetime, null: false) 18 | add(:end_time, :utc_datetime) 19 | 20 | add(:user_id, references(:jellyfin_users, on_delete: :nilify_all)) 21 | add(:server_id, references(:servers, on_delete: :delete_all), null: false) 22 | 23 | timestamps() 24 | end 25 | 26 | create(index(:playback_sessions, [:server_id])) 27 | create(index(:playback_sessions, [:user_id])) 28 | create(index(:playback_sessions, [:start_time])) 29 | create(index(:playback_sessions, [:item_jellyfin_id])) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250330121156_add_playback_tracking_fields.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddPlaybackTrackingFields do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:playback_sessions) do 6 | add(:position_ticks, :bigint) 7 | add(:runtime_ticks, :bigint) 8 | add(:percent_complete, :float) 9 | add(:completed, :boolean, default: false) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250330122813_drop_legacy_playback_tables.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.DropLegacyPlaybackTables do 2 | use Ecto.Migration 3 | 4 | def up do 5 | # Drop the old table 6 | drop_if_exists(table(:playback_activities)) 7 | 8 | # Drop any indexes that might exist 9 | drop_if_exists(index(:playback_activities, [:server_id])) 10 | drop_if_exists(index(:playback_activities, [:user_id])) 11 | drop_if_exists(index(:playback_activities, [:rowid, :server_id])) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250330130544_alter_more_string_columns.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AlterMoreStringColumns do 2 | use Ecto.Migration 3 | 4 | def change do 5 | # Add for libraries table if needed 6 | alter table(:jellyfin_libraries) do 7 | modify(:name, :text) 8 | modify(:type, :text) 9 | end 10 | 11 | # Add for activities table if needed 12 | alter table(:activities) do 13 | modify(:name, :text) 14 | modify(:short_overview, :text) 15 | modify(:type, :text) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250401165856_add_image_fields_to_items.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddImageFieldsToItems do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:jellyfin_items) do 6 | add(:primary_image_tag, :text) 7 | add(:backdrop_image_tags, {:array, :text}) 8 | end 9 | 10 | # Create an index to optimize queries that filter by image tags 11 | create index(:jellyfin_items, [:primary_image_tag]) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250403065756_update_jellyfind_items_image_fields.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.UpdateJellyfindItemsImageFields do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:jellyfin_items) do 6 | # Remove fields 7 | remove :series_primary_image_tag 8 | remove :series_studio 9 | 10 | # Add new field 11 | add :image_blur_hashes, :map 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250403073228_add_more_image_fields_to_jellyfin_items.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddMoreImageFieldsToJellyfinItems do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:jellyfin_items) do 6 | add :parent_index_number, :integer 7 | add :video_type, :string 8 | add :has_subtitles, :boolean 9 | add :channel_id, :string 10 | add :parent_backdrop_item_id, :string 11 | add :parent_backdrop_image_tags, {:array, :string} 12 | add :parent_thumb_item_id, :string 13 | add :parent_thumb_image_tag, :string 14 | add :location_type, :string 15 | add :primary_image_aspect_ratio, :float 16 | add :series_primary_image_tag, :string 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250403084831_add_image_tags_to_items.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddImageTagsToItems do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:jellyfin_items) do 6 | add :primary_image_thumb_tag, :string 7 | add :primary_image_logo_tag, :string 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250404120602_add_statistics_indexes.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddStatisticsIndexes do 2 | use Ecto.Migration 3 | 4 | def change do 5 | # Indexes for PlaybackSession table 6 | create_if_not_exists index(:playback_sessions, [:start_time]) 7 | create_if_not_exists index(:playback_sessions, [:server_id]) 8 | create_if_not_exists index(:playback_sessions, [:user_jellyfin_id]) 9 | create_if_not_exists index(:playback_sessions, [:item_jellyfin_id]) 10 | create_if_not_exists index(:playback_sessions, [:server_id, :start_time]) 11 | create_if_not_exists index(:playback_sessions, [:server_id, :user_jellyfin_id]) 12 | 13 | # Compound indexes for common query patterns 14 | create_if_not_exists index(:playback_sessions, [:server_id, :user_jellyfin_id, :start_time]) 15 | create_if_not_exists index(:playback_sessions, [:server_id, :item_jellyfin_id]) 16 | 17 | # Indexes for Item table 18 | create_if_not_exists index(:jellyfin_items, [:server_id]) 19 | create_if_not_exists index(:jellyfin_items, [:jellyfin_id]) 20 | create_if_not_exists index(:jellyfin_items, [:type]) 21 | create_if_not_exists index(:jellyfin_items, [:server_id, :jellyfin_id]) 22 | create_if_not_exists index(:jellyfin_items, [:server_id, :type]) 23 | 24 | # Index for efficient joining 25 | create_if_not_exists index(:playback_sessions, [:server_id, :item_jellyfin_id, :start_time]) 26 | 27 | # Index for efficient sorting of watch time 28 | create_if_not_exists index(:playback_sessions, [:play_duration]) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250420203410_remove_duplicate_playback_sessions.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.RemoveDuplicatePlaybackSessions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | # Create a temporary function to find duplicates and keep only the latest version 6 | execute """ 7 | CREATE OR REPLACE FUNCTION deduplicate_playback_sessions() RETURNS void AS $$ 8 | DECLARE 9 | duplicate_record RECORD; 10 | BEGIN 11 | FOR duplicate_record IN ( 12 | SELECT 13 | item_jellyfin_id, 14 | user_jellyfin_id, 15 | start_time, 16 | server_id, 17 | COUNT(*) as count, 18 | array_agg(id ORDER BY updated_at DESC) as ids 19 | FROM 20 | playback_sessions 21 | GROUP BY 22 | item_jellyfin_id, user_jellyfin_id, start_time, server_id 23 | HAVING 24 | COUNT(*) > 1 25 | ) LOOP 26 | -- Keep the first ID (most recently updated) and delete the rest 27 | DELETE FROM playback_sessions 28 | WHERE id = ANY(duplicate_record.ids[2:array_length(duplicate_record.ids, 1)]); 29 | END LOOP; 30 | END; 31 | $$ LANGUAGE plpgsql; 32 | """ 33 | 34 | # Execute the function to deduplicate records 35 | execute "SELECT deduplicate_playback_sessions();" 36 | 37 | # Clean up - drop the temporary function 38 | execute "DROP FUNCTION deduplicate_playback_sessions();" 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250420203437_add_unique_index_to_playback_sessions.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddUniqueIndexToPlaybackSessions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | # Create a unique index to prevent duplicate playback sessions 6 | create unique_index(:playback_sessions, [:item_jellyfin_id, :user_jellyfin_id, :start_time, :server_id], 7 | name: :playback_sessions_unique_index) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250423074010_add_fields_to_playback_sessions.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddFieldsToPlaybackSessions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:playback_sessions) do 6 | add :is_paused, :boolean 7 | add :is_muted, :boolean 8 | add :volume_level, :integer 9 | add :audio_stream_index, :integer 10 | add :subtitle_stream_index, :integer 11 | add :media_source_id, :string 12 | add :repeat_mode, :string 13 | add :playback_order, :string 14 | add :remote_end_point, :string 15 | add :session_id, :string 16 | add :user_name, :string 17 | add :last_activity_date, :utc_datetime 18 | add :last_playback_check_in, :utc_datetime 19 | add :application_version, :string 20 | add :is_active, :boolean 21 | add :transcoding_audio_codec, :string 22 | add :transcoding_video_codec, :string 23 | add :transcoding_container, :string 24 | add :transcoding_is_video_direct, :boolean 25 | add :transcoding_is_audio_direct, :boolean 26 | add :transcoding_bitrate, :integer 27 | add :transcoding_completion_percentage, :float 28 | add :transcoding_width, :integer 29 | add :transcoding_height, :integer 30 | add :transcoding_audio_channels, :integer 31 | add :transcoding_hardware_acceleration_type, :string 32 | add :transcoding_reasons, {:array, :string} 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250428183037_add_people_to_jellyfin_items.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddPeopleToJellyfinItems do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:jellyfin_items) do 6 | add :people, {:array, :map} 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250428183624_add_pgvector_extension.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddPgvectorExtension do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute("CREATE EXTENSION IF NOT EXISTS vector") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250428183712_add_embedding_to_items.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddEmbeddingToItems do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:jellyfin_items) do 6 | add :embedding, :vector, size: 1536 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250501072643_add_openai_token_to_servers.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddOpenaiTokenToServers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:servers) do 6 | add :open_ai_api_token, :string 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250502190555_add_removed_at_to_jellyfin_items.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddRemovedAtToJellyfinItems do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:jellyfin_items) do 6 | add :removed_at, :utc_datetime 7 | end 8 | 9 | create index(:jellyfin_items, [:removed_at]) 10 | end 11 | end -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250504100042_add_removed_at_to_libraries.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddRemovedAtToLibraries do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:jellyfin_libraries) do 6 | add(:removed_at, :utc_datetime) 7 | end 8 | end 9 | end -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250519081814_add_auto_generate_embeddings_to_servers.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddAutoGenerateEmbeddingsToServers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:servers) do 6 | add :auto_generate_embeddings, :boolean, default: false, null: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250521094546_cleanup_after_jellyfin_id_migration.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.CleanupAfterJellyfinIdMigration do 2 | use Ecto.Migration 3 | 4 | def up do 5 | # This migration will run after all the other jellyfin_id migrations have completed 6 | 7 | # Remove legacy item_id field from activities 8 | alter table(:activities) do 9 | remove_if_exists :item_id, :string 10 | end 11 | 12 | # Update any views, triggers, or other database objects that may reference the old id column 13 | # None identified in the current code 14 | 15 | # Optional: Add check constraints to ensure data consistency 16 | execute "ALTER TABLE jellyfin_items ADD CONSTRAINT check_jellyfin_id_not_null CHECK (jellyfin_id IS NOT NULL)" 17 | end 18 | 19 | def down do 20 | # Add back the legacy item_id field to activities 21 | alter table(:activities) do 22 | add_if_not_exists :item_id, :string 23 | end 24 | 25 | # Remove any constraints added in the up migration 26 | execute "ALTER TABLE jellyfin_items DROP CONSTRAINT IF EXISTS check_jellyfin_id_not_null" 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250521095240_fix_activities_item_id_column.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.FixActivitiesItemIdColumn do 2 | use Ecto.Migration 3 | 4 | def up do 5 | # Check if the item_id column exists using a DO block 6 | execute """ 7 | DO $$ 8 | BEGIN 9 | IF EXISTS ( 10 | SELECT 1 11 | FROM information_schema.columns 12 | WHERE table_name = 'activities' AND column_name = 'item_id' 13 | ) THEN 14 | ALTER TABLE activities DROP COLUMN item_id; 15 | END IF; 16 | END $$; 17 | """ 18 | 19 | # Check if item_jellyfin_id column exists, and add it if not 20 | execute """ 21 | DO $$ 22 | BEGIN 23 | IF NOT EXISTS ( 24 | SELECT 1 25 | FROM information_schema.columns 26 | WHERE table_name = 'activities' AND column_name = 'item_jellyfin_id' 27 | ) THEN 28 | ALTER TABLE activities ADD COLUMN item_jellyfin_id text; 29 | END IF; 30 | END $$; 31 | """ 32 | end 33 | 34 | def down do 35 | # Add back the item_id column if needed 36 | execute """ 37 | DO $$ 38 | BEGIN 39 | IF NOT EXISTS ( 40 | SELECT 1 41 | FROM information_schema.columns 42 | WHERE table_name = 'activities' AND column_name = 'item_id' 43 | ) THEN 44 | ALTER TABLE activities ADD COLUMN item_id text; 45 | END IF; 46 | END $$; 47 | """ 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250521102552_change_activities_primary_key_to_jellyfin_id.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.ChangeActivitiesPrimaryKeyToJellyfinId do 2 | use Ecto.Migration 3 | 4 | def up do 5 | # First create a unique index on jellyfin_id to ensure it can be a primary key 6 | execute "CREATE UNIQUE INDEX IF NOT EXISTS idx_activities_jellyfin_id_server_id ON activities (jellyfin_id, server_id)" 7 | 8 | # Drop the primary key constraint 9 | execute "ALTER TABLE activities DROP CONSTRAINT activities_pkey" 10 | 11 | # Add a primary key constraint based on jellyfin_id 12 | # Note: We're keeping server_id as part of the composite primary key since activities 13 | # are scoped to servers (and jellyfin_id is unique within a server) 14 | execute "ALTER TABLE activities ADD PRIMARY KEY (jellyfin_id, server_id)" 15 | 16 | # Drop the auto-generated ID column as it's no longer needed 17 | execute "ALTER TABLE activities DROP COLUMN IF EXISTS id" 18 | end 19 | 20 | def down do 21 | # To undo this migration, we need to: 22 | # 1. Add back the id column 23 | # 2. Populate it with unique values 24 | # 3. Set it as the primary key 25 | 26 | execute "ALTER TABLE activities DROP CONSTRAINT activities_pkey" 27 | 28 | # Add back the id column 29 | execute "ALTER TABLE activities ADD COLUMN id SERIAL" 30 | 31 | # Set it as the primary key 32 | execute "ALTER TABLE activities ADD PRIMARY KEY (id)" 33 | 34 | # Keep the unique index on jellyfin_id and server_id 35 | execute "CREATE UNIQUE INDEX IF NOT EXISTS idx_activities_jellyfin_id_server_id ON activities (jellyfin_id, server_id)" 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250521104127_remove_user_server_id_from_activities.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.RemoveUserServerIdFromActivities do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:activities) do 6 | remove :user_server_id 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250521132646_remove_playback_session_foreign_key_constraints.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.RemovePlaybackSessionForeignKeyConstraints do 2 | use Ecto.Migration 3 | 4 | def up do 5 | # Remove foreign key constraint from playback_sessions 6 | drop constraint(:playback_sessions, :playback_sessions_item_jellyfin_id_fkey) 7 | 8 | # Modify the column to be a string without constraints 9 | alter table(:playback_sessions) do 10 | modify :item_jellyfin_id, :string 11 | end 12 | end 13 | 14 | def down do 15 | # Add the foreign key constraint back to playback_sessions 16 | alter table(:playback_sessions) do 17 | modify :item_jellyfin_id, references(:jellyfin_items, column: :jellyfin_id, type: :string, on_delete: :nilify_all) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250523041752_add_ollama_fields_to_servers.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddOllamaFieldsToServers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:servers) do 6 | add :ollama_api_token, :string 7 | add :ollama_base_url, :string, default: "http://localhost:11434" 8 | add :ollama_model, :string, default: "nomic-embed-text" 9 | add :embedding_provider, :string, default: "openai" 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250524154906_add_missing_sync_tracking_to_items.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddMissingSyncTrackingToItems do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:jellyfin_items) do 6 | add :missing_sync_count, :integer, default: 0 7 | add :first_missing_at, :utc_datetime 8 | end 9 | 10 | # Add index for performance on the query that finds items to remove 11 | create index(:jellyfin_items, [:server_id, :missing_sync_count, :first_missing_at]) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/20250524164743_fix_hidden_recommendations_constraint.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.FixHiddenRecommendationsConstraint do 2 | use Ecto.Migration 3 | 4 | def up do 5 | # Drop the existing unique index if it exists 6 | drop_if_exists unique_index(:hidden_recommendations, [:user_jellyfin_id, :item_jellyfin_id, :server_id]) 7 | 8 | # Create the unique index with the specific name that matches the constraint in the model 9 | create unique_index(:hidden_recommendations, [:user_jellyfin_id, :item_jellyfin_id, :server_id], 10 | name: "hidden_recommendations_user_jellyfin_id_item_jellyfin_id_server") 11 | end 12 | 13 | def down do 14 | # Drop the named unique index 15 | drop_if_exists index(:hidden_recommendations, [:user_jellyfin_id, :item_jellyfin_id, :server_id], 16 | name: "hidden_recommendations_user_jellyfin_id_item_jellyfin_id_server") 17 | 18 | # Recreate the original unnamed unique index 19 | create unique_index(:hidden_recommendations, [:user_jellyfin_id, :item_jellyfin_id, :server_id]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /server/priv/repo/migrations/999999999_add_hidden_recommendations.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.Repo.Migrations.AddHiddenRecommendations do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:hidden_recommendations) do 6 | add :user_jellyfin_id, :string, null: false 7 | add :item_jellyfin_id, :string, null: false 8 | add :server_id, references(:servers, on_delete: :delete_all), null: false 9 | add :hidden_at, :utc_datetime, null: false, default: fragment("now()") 10 | 11 | timestamps() 12 | end 13 | 14 | create index(:hidden_recommendations, [:user_jellyfin_id, :server_id]) 15 | create index(:hidden_recommendations, [:item_jellyfin_id, :server_id]) 16 | create unique_index(:hidden_recommendations, [:user_jellyfin_id, :item_jellyfin_id, :server_id]) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /server/priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # StreamystatServer.Repo.insert!(%StreamystatServer.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /server/rel/overlays/bin/migrate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | cd -P -- "$(dirname -- "$0")" 5 | exec ./streamystat_server eval StreamystatServer.Release.migrate 6 | -------------------------------------------------------------------------------- /server/rel/overlays/bin/migrate.bat: -------------------------------------------------------------------------------- 1 | call "%~dp0\streamystat_server" eval StreamystatServer.Release.migrate 2 | -------------------------------------------------------------------------------- /server/rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | cd -P -- "$(dirname -- "$0")" 5 | PHX_SERVER=true exec ./streamystat_server start 6 | -------------------------------------------------------------------------------- /server/rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | set PHX_SERVER=true 2 | call "%~dp0\streamystat_server" start 3 | -------------------------------------------------------------------------------- /server/test/streamystat_server_web/controllers/error_json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.ErrorJSONTest do 2 | use StreamystatServerWeb.ConnCase, async: true 3 | 4 | test "renders 404" do 5 | assert StreamystatServerWeb.ErrorJSON.render("404.json", %{}) == %{ 6 | errors: %{detail: "Not Found"} 7 | } 8 | end 9 | 10 | test "renders 500" do 11 | assert StreamystatServerWeb.ErrorJSON.render("500.json", %{}) == 12 | %{errors: %{detail: "Internal Server Error"}} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /server/test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServerWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use StreamystatServerWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # The default endpoint for testing 23 | @endpoint StreamystatServerWeb.Endpoint 24 | 25 | use StreamystatServerWeb, :verified_routes 26 | 27 | # Import conveniences for testing with connections 28 | import Plug.Conn 29 | import Phoenix.ConnTest 30 | import StreamystatServerWeb.ConnCase 31 | end 32 | end 33 | 34 | setup tags do 35 | StreamystatServer.DataCase.setup_sandbox(tags) 36 | {:ok, conn: Phoenix.ConnTest.build_conn()} 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /server/test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamystatServer.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use StreamystatServer.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias StreamystatServer.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import StreamystatServer.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | StreamystatServer.DataCase.setup_sandbox(tags) 32 | :ok 33 | end 34 | 35 | @doc """ 36 | Sets up the sandbox based on the test tags. 37 | """ 38 | def setup_sandbox(tags) do 39 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(StreamystatServer.Repo, shared: not tags[:async]) 40 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 41 | end 42 | 43 | @doc """ 44 | A helper that transforms changeset errors into a map of messages. 45 | 46 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 47 | assert "password is too short" in errors_on(changeset).password 48 | assert %{password: ["password is too short"]} = errors_on(changeset) 49 | 50 | """ 51 | def errors_on(changeset) do 52 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 53 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 54 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 55 | end) 56 | end) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /server/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(StreamystatServer.Repo, :manual) 3 | --------------------------------------------------------------------------------