├── .dockerignore
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.yaml
│ └── feature_request.md
└── workflows
│ ├── build-and-push-docker.yml
│ ├── build-and-release-binary.yml
│ ├── commitlint.yml
│ ├── docs.yml
│ ├── main.yml
│ ├── release-please-prerelease.yml
│ └── release-please.yml
├── .gitignore
├── .husky
├── commit-msg
└── pre-commit
├── .prettierignore
├── .prettierrc
├── .release-please-manifest.json
├── CHANGELOG.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── commitlint.config.ts
├── design
├── error.png
├── tunarr-channels.png
└── tunarr-guide.png
├── docker
├── dev.compose.yaml
├── example.compose.yaml
└── nexe-windows.Dockerfile
├── docs
├── CNAME
├── api-docs.html
├── assets
│ ├── add-media-source.png
│ ├── channel-properties.png
│ ├── channels-flex-filler.png
│ ├── channels-new.png
│ ├── docker-desktop.webp
│ ├── flex-filler-content.png
│ ├── library-filler-additems.png
│ ├── library-filler-menu.png
│ ├── library-filler-new-addmedia.png
│ ├── library-filler-new-addmedia2.png
│ ├── library-filler-new.png
│ ├── library-filler-save.png
│ ├── library-filler.png
│ ├── library.png
│ ├── misc-commonissues-channelmappings.png
│ ├── new-plex-server-manual.png
│ ├── plex-settings-channels.png
│ ├── plex-settings-dvr.png
│ ├── plex-settings-guide.png
│ ├── plex-settings-tuner.png
│ ├── plex-settings-tuner2.png
│ ├── plex-settings-viewguide.png
│ ├── plex-settings-xmltv.png
│ ├── plex-settings.png
│ ├── programming-additem.png
│ ├── programming-addmedia-library.png
│ ├── programming-addmedia.png
│ ├── programming-addshow.png
│ ├── programming-blockshuffle-noloop.png
│ ├── programming-blockshuffle.png
│ ├── programming-expandmenu.png
│ ├── programming-mediaadded.png
│ ├── programming-shuffle.png
│ ├── scheduling-tools-random_slots.png
│ ├── scheduling-tools-random_slots_example.png
│ ├── scheduling-tools-random_slots_example_weighted.png
│ ├── scheduling-tools-random_slots_preview.png
│ ├── scheduling-tools-random_slots_preview_weighted.png
│ ├── scheduling-tools-time_slots.png
│ ├── scheduling-tools-time_slots_example.png
│ ├── scheduling-tools-time_slots_exampleflex.png
│ ├── scheduling-tools-time_slots_preview.png
│ ├── scheduling-tools-time_slots_previewflex.png
│ ├── serversettings-autoupdatechannels.png
│ ├── settings-sources-edit.png
│ ├── setup-finish.png
│ ├── tunarr.png
│ ├── watermark_form.png
│ ├── welcome_page.png
│ ├── welcome_page_ffmpeg_installed.png
│ └── welcome_page_not_connected.png
├── channel-config.png
├── channels.png
├── configure
│ ├── channels
│ │ ├── epg.md
│ │ ├── flex.md
│ │ ├── index.md
│ │ ├── properties.md
│ │ └── transcoding.md
│ ├── clients
│ │ ├── index.md
│ │ ├── jellyfin.md
│ │ └── plex.md
│ ├── library
│ │ ├── custom-shows.md
│ │ ├── filler.md
│ │ └── index.md
│ ├── programming.md
│ ├── scheduling-tools
│ │ ├── balance.md
│ │ ├── consolidate.md
│ │ ├── index.md
│ │ ├── random-slots.md
│ │ ├── replicate.md
│ │ └── time-slots.md
│ ├── system
│ │ ├── ffmpeg.md
│ │ ├── index.md
│ │ └── security.md
│ └── transcoding.md
├── dev
│ └── contributing.md
├── generated
│ ├── .gitkeep
│ ├── tunarr-latest-openapi.json
│ ├── tunarr-v0.18.2-openapi.json
│ └── tunarr-v0.19.1-openapi.json
├── getting-started
│ ├── installation.md
│ ├── run.md
│ └── setup.md
├── index.md
├── misc
│ ├── common-issues.md
│ └── troubleshooting.md
├── plex-guide.png
├── plex-stream.png
└── stylesheets
│ └── extra.css
├── eslint.config.js
├── mkdocs.yml
├── package.json
├── patches
├── .gitkeep
├── kysely.patch
└── ts-essentials@9.4.1.patch
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── release-please-config-prerelease.json
├── release-please-config.json
├── scripts
└── init-husky.sh
├── server
├── .gitignore
├── bunfig.toml
├── cjs-shim.ts
├── drizzle.config.ts
├── drizzle
│ ├── 0000_talented_grey_gargoyle.sql
│ ├── 0001_orange_alex_power.sql
│ ├── 0002_violet_sheva_callister.sql
│ ├── 0003_ancient_synch.sql
│ ├── 0004_smooth_gorilla_man.sql
│ ├── 0005_fair_whistler.sql
│ ├── 0006_young_freak.sql
│ ├── 0007_wise_scream.sql
│ ├── 0008_gray_jean_grey.sql
│ └── meta
│ │ ├── 0000_snapshot.json
│ │ ├── 0001_snapshot.json
│ │ ├── 0002_snapshot.json
│ │ ├── 0003_snapshot.json
│ │ ├── 0004_snapshot.json
│ │ ├── 0005_snapshot.json
│ │ ├── 0006_snapshot.json
│ │ ├── 0007_snapshot.json
│ │ ├── 0008_snapshot.json
│ │ └── _journal.json
├── esbuild
│ ├── bundlerPathsOverrideShim.ts
│ ├── importMetaUrlShim.ts
│ ├── native-node-module.ts
│ └── node-protocol.ts
├── package.json
├── pkg.config.json
├── schema.prisma
├── scripts
│ ├── bundle-old.ts
│ ├── bundle.ts
│ └── make-bin.ts
├── settings.json
├── src
│ ├── Server.ts
│ ├── ServerContext.ts
│ ├── api
│ │ ├── channelsApi.ts
│ │ ├── customShowsApi.ts
│ │ ├── debug
│ │ │ ├── debugFfmpegApi.ts
│ │ │ ├── debugJellyfinApi.ts
│ │ │ ├── debugPlexApi.ts
│ │ │ └── debugStreamApi.ts
│ │ ├── debugApi.ts
│ │ ├── embyApi.ts
│ │ ├── ffmpegSettingsApi.ts
│ │ ├── fillerListsApi.ts
│ │ ├── guideApi.ts
│ │ ├── hdhrApi.ts
│ │ ├── hdhrSettingsApi.ts
│ │ ├── index.ts
│ │ ├── jellyfinApi.ts
│ │ ├── mediaSourceApi.ts
│ │ ├── metadataApi.ts
│ │ ├── plexSettingsApi.ts
│ │ ├── programmingApi.ts
│ │ ├── sessionApi.ts
│ │ ├── streamApi.ts
│ │ ├── systemApi.ts
│ │ ├── tasksApi.ts
│ │ ├── videoApi.ts
│ │ └── xmltvSettingsApi.ts
│ ├── bootstrap.ts
│ ├── cli
│ │ ├── GenerateOpenApiCommand.ts
│ │ ├── RunServerCommand.ts
│ │ ├── commands.ts
│ │ ├── database
│ │ │ ├── DatabaseListMigrationsCommand.ts
│ │ │ ├── DatabaseMigrateDownCommand.ts
│ │ │ ├── DatabaseMigrateToLatestCommand.ts
│ │ │ ├── DatabaseMigrateUpCommand .ts
│ │ │ ├── databaseCommandUtil.ts
│ │ │ └── databaseCommands.ts
│ │ ├── legacyMigrateCommand.ts
│ │ ├── runFixerCommand.ts
│ │ ├── settings
│ │ │ ├── settingsCommands.ts
│ │ │ ├── settingsUpdateCommand.ts
│ │ │ └── settingsViewCommand.ts
│ │ └── types.ts
│ ├── container.ts
│ ├── db
│ │ ├── ChannelDB.ts
│ │ ├── ChannelQueryBuilder.ts
│ │ ├── CustomShowDB.ts
│ │ ├── DBAccess.ts
│ │ ├── DBModule.ts
│ │ ├── FillerListDB.ts
│ │ ├── ProgramDB.ts
│ │ ├── SettingsDB.test.ts
│ │ ├── SettingsDB.ts
│ │ ├── SettingsDBFactory.ts
│ │ ├── TranscodeConfigDB.ts
│ │ ├── backup
│ │ │ ├── ArchiveDatabaseBackup.ts
│ │ │ ├── DatabaseBackup.ts
│ │ │ ├── DatabaseBackupStrategy.ts
│ │ │ └── SqliteDatabaseBackup.ts
│ │ ├── converters
│ │ │ ├── ProgramConverter.ts
│ │ │ ├── ProgramGroupingMinter.ts
│ │ │ ├── ProgramMinter.ts
│ │ │ ├── channelConverters.ts
│ │ │ └── transcodeConfigConverters.ts
│ │ ├── custom_types
│ │ │ ├── ProgramExternalIdType.ts
│ │ │ └── ProgramSourceType.ts
│ │ ├── derived_types
│ │ │ ├── Lineup.ts
│ │ │ └── StreamLineup.ts
│ │ ├── interfaces
│ │ │ ├── IChannelDB.ts
│ │ │ ├── IProgramDB.ts
│ │ │ └── ISettingsDB.ts
│ │ ├── json
│ │ │ ├── InMemoryCachedDbAdapter.ts
│ │ │ ├── SchemaBackedJsonDBAdapter.ts
│ │ │ └── SyncSchemaBackedJSONDBAdapter.ts
│ │ ├── mediaSourceDB.ts
│ │ ├── migrationUtil.ts
│ │ ├── programHelpers.ts
│ │ ├── programQueryHelpers.ts
│ │ └── schema
│ │ │ ├── CachedImage.ts
│ │ │ ├── Channel.ts
│ │ │ ├── CustomShow.ts
│ │ │ ├── FillerShow.ts
│ │ │ ├── KyselifyBetter.ts
│ │ │ ├── MediaSource.ts
│ │ │ ├── MikroOrmMigrations.d.ts
│ │ │ ├── Program.ts
│ │ │ ├── ProgramExternalId.ts
│ │ │ ├── ProgramGrouping.ts
│ │ │ ├── ProgramGroupingExternalId.ts
│ │ │ ├── SubtitlePreferences.ts
│ │ │ ├── TranscodeConfig.ts
│ │ │ ├── base.ts
│ │ │ ├── db.ts
│ │ │ └── derivedTypes.d.ts
│ ├── external
│ │ ├── BaseApiClient.ts
│ │ ├── MediaSourceApiFactory.ts
│ │ ├── Redacter.ts
│ │ ├── emby
│ │ │ ├── EmbyApiClient.ts
│ │ │ └── EmbyRequestRedacter.ts
│ │ ├── jellyfin
│ │ │ ├── JellyfinApiClient.ts
│ │ │ ├── JellyfinItemFinder.ts
│ │ │ └── JellyfinRequestRedacter.ts
│ │ └── plex
│ │ │ ├── PlexApiClient.ts
│ │ │ ├── PlexQueryCache.ts
│ │ │ └── PlexRequestRedacter.ts
│ ├── ffmpeg
│ │ ├── FFmpegModule.ts
│ │ ├── FfmpegPlaybackParamsCalculator.ts
│ │ ├── FfmpegProcess.ts
│ │ ├── FfmpegStreamFactory.ts
│ │ ├── FfmpegTrancodeSession.ts
│ │ ├── GetLastPtsDuration.test.ts
│ │ ├── GetLastPtsDuration.ts
│ │ ├── SubtitleStreamPicker.ts
│ │ ├── builder
│ │ │ ├── FfmpegCommandGenerator.test.ts
│ │ │ ├── FfmpegCommandGenerator.ts
│ │ │ ├── MediaStream.test.ts
│ │ │ ├── MediaStream.ts
│ │ │ ├── capabilities
│ │ │ │ ├── BaseFfmpegHardwareCapabilities.ts
│ │ │ │ ├── DefaultHardwareCapabilities.ts
│ │ │ │ ├── FfmpegCapabilities.ts
│ │ │ │ ├── HardwareCapabilitiesFactory.ts
│ │ │ │ ├── NoHardwareCapabilities.ts
│ │ │ │ ├── NvidiaHardwareCapabilities.ts
│ │ │ │ ├── NvidiaHardwareCapabilitiesFactory.test.ts
│ │ │ │ ├── NvidiaHardwareCapabilitiesFactory.ts
│ │ │ │ ├── QsvHardwareCapabilities.ts
│ │ │ │ ├── QsvHardwareCapabilitiesFactory.ts
│ │ │ │ ├── VaapiHardwareCapabilities.ts
│ │ │ │ ├── VaapiHardwareCapabilitiesFactory.ts
│ │ │ │ ├── VaapiHardwareCapabilitiesParser.test.ts
│ │ │ │ ├── VaapiHardwareCapabilitiesParser.ts
│ │ │ │ └── VainfoProcessHelper.ts
│ │ │ ├── constants.ts
│ │ │ ├── decoder
│ │ │ │ ├── Av1Decoder.ts
│ │ │ │ ├── BaseDecoder.ts
│ │ │ │ ├── Decoder.ts
│ │ │ │ ├── DecoderFactory.ts
│ │ │ │ ├── H264Decoder.ts
│ │ │ │ ├── HevcDecoder.ts
│ │ │ │ ├── ImplicitDecoder.ts
│ │ │ │ ├── Mpeg2Decoder.ts
│ │ │ │ ├── Mpeg4Decoder.ts
│ │ │ │ ├── RawVideoDecoder.ts
│ │ │ │ ├── SoftwareDecoder.ts
│ │ │ │ ├── Vc1Decoder.ts
│ │ │ │ ├── nvidia
│ │ │ │ │ ├── NvidiaAv1Decoder.ts
│ │ │ │ │ ├── NvidiaDecoder.ts
│ │ │ │ │ ├── NvidiaH264Decoder.ts
│ │ │ │ │ ├── NvidiaHevcDecoder.ts
│ │ │ │ │ ├── NvidiaImplicitDecoder.ts
│ │ │ │ │ ├── NvidiaMpeg2Decoder.ts
│ │ │ │ │ ├── NvidiaVc1Decoder.ts
│ │ │ │ │ └── NvidiaVp9Decoder.ts
│ │ │ │ ├── qsv
│ │ │ │ │ ├── Av1QsvDecoder.ts
│ │ │ │ │ ├── H264QsvDecoder.ts
│ │ │ │ │ ├── HevcQsvDecoder.ts
│ │ │ │ │ ├── Mpeg2QsvDecoder.ts
│ │ │ │ │ ├── QsvDecoder.ts
│ │ │ │ │ ├── Vc1QsvDecoder.ts
│ │ │ │ │ └── Vp9QsvDecoder.ts
│ │ │ │ ├── vaapi
│ │ │ │ │ └── VaapiDecoder.ts
│ │ │ │ └── videotoolbox
│ │ │ │ │ └── VideoToolboxDecoder.ts
│ │ │ ├── encoder
│ │ │ │ ├── BaseEncoder.ts
│ │ │ │ ├── CopyEncoders.ts
│ │ │ │ ├── CopyVideoEncoder.ts
│ │ │ │ ├── Encoder.ts
│ │ │ │ ├── ImplicitVideoEncoder.ts
│ │ │ │ ├── LibKvazaarEncoder.ts
│ │ │ │ ├── LibOpenH264Encoder.ts
│ │ │ │ ├── Libx264Encoder.ts
│ │ │ │ ├── Libx265Encoder.ts
│ │ │ │ ├── Mpeg2VideoEncoder.ts
│ │ │ │ ├── RawVideoEncoder.ts
│ │ │ │ ├── audio
│ │ │ │ │ └── PcmS16LeAudioEncoder.ts
│ │ │ │ ├── nvidia
│ │ │ │ │ └── NvidiaEncoders.ts
│ │ │ │ ├── qsv
│ │ │ │ │ ├── H264QsvEncoder.ts
│ │ │ │ │ ├── HevcQsvEncoder.ts
│ │ │ │ │ ├── Mpeg2QsvEncoder.ts
│ │ │ │ │ └── QsvEncoders.ts
│ │ │ │ ├── vaapi
│ │ │ │ │ └── VaapiEncoders.ts
│ │ │ │ └── videotoolbox
│ │ │ │ │ └── VideoToolboxEncoders.ts
│ │ │ ├── filter
│ │ │ │ ├── AudioPadFilter.ts
│ │ │ │ ├── AudioResampleFilter.ts
│ │ │ │ ├── ComplexFilter.ts
│ │ │ │ ├── DeinterlaceFilter.ts
│ │ │ │ ├── FilterChain.ts
│ │ │ │ ├── FilterOption.ts
│ │ │ │ ├── HardwareDownloadFilter.test.ts
│ │ │ │ ├── HardwareDownloadFilter.ts
│ │ │ │ ├── ImageScaleFilter.ts
│ │ │ │ ├── LoopFilter.ts
│ │ │ │ ├── PadFilter.test.ts
│ │ │ │ ├── PadFilter.ts
│ │ │ │ ├── PipelineFilterStep.ts
│ │ │ │ ├── PixelFormatFilter.ts
│ │ │ │ ├── RealtimeFilter.ts
│ │ │ │ ├── ScaleFilter.ts
│ │ │ │ ├── SineWaveGeneratorFilter.ts
│ │ │ │ ├── StaticFilter.ts
│ │ │ │ ├── StreamSeekFilter.ts
│ │ │ │ ├── SubtitleFilter.ts
│ │ │ │ ├── SubtitleOverlayFilter.ts
│ │ │ │ ├── TitleTextFilter.ts
│ │ │ │ ├── nvidia
│ │ │ │ │ ├── FormatCudaFilter.test.ts
│ │ │ │ │ ├── FormatCudaFilter.ts
│ │ │ │ │ ├── HardwareDownloadCudaFilter.test.ts
│ │ │ │ │ ├── HardwareDownloadCudaFilter.ts
│ │ │ │ │ ├── HardwareUploadCudaFilter.ts
│ │ │ │ │ ├── OverlaySubtitleCudaFilter.ts
│ │ │ │ │ ├── OverlayWatermarkCudaFilter.ts
│ │ │ │ │ ├── ScaleCudaFilter.test.ts
│ │ │ │ │ ├── ScaleCudaFilter.ts
│ │ │ │ │ ├── ScaleNppFilter.ts
│ │ │ │ │ ├── SubtitleScaleCudaFilter.ts
│ │ │ │ │ ├── SubtitleScaleNppFilter.ts
│ │ │ │ │ └── YadifCudaFilter.ts
│ │ │ │ ├── qsv
│ │ │ │ │ ├── DeinterlaceQsvFilter.ts
│ │ │ │ │ ├── HardwareUploadQsvFilter.ts
│ │ │ │ │ ├── QsvFormatFilter.ts
│ │ │ │ │ └── ScaleQsvFilter.ts
│ │ │ │ ├── vaapi
│ │ │ │ │ ├── DeinterlaceVaapiFilter.ts
│ │ │ │ │ ├── HardwareUploadVaapiFilter.ts
│ │ │ │ │ ├── ScaleSubtitlesVaapiFilter.ts
│ │ │ │ │ ├── ScaleVaapiFilter.test.ts
│ │ │ │ │ ├── ScaleVaapiFilter.ts
│ │ │ │ │ ├── VaapiFormatFilter.test.ts
│ │ │ │ │ ├── VaapiFormatFilter.ts
│ │ │ │ │ ├── VaapiOverlayFilter.ts
│ │ │ │ │ └── VaapiSubtitlePixelFormatFilter.ts
│ │ │ │ ├── videotoolbox
│ │ │ │ │ └── VideoToolboxHardwareAccelerationOption.ts
│ │ │ │ └── watermark
│ │ │ │ │ ├── OverlayWatermarkFilter.ts
│ │ │ │ │ ├── WatermarkFadeFilter.ts
│ │ │ │ │ ├── WatermarkOpacityFilter.ts
│ │ │ │ │ ├── WatermarkPixelFormatFilter.ts
│ │ │ │ │ └── WatermarkScaleFilter.ts
│ │ │ ├── format
│ │ │ │ └── PixelFormat.ts
│ │ │ ├── input
│ │ │ │ ├── AudioInputSource.ts
│ │ │ │ ├── ConcatInputSource.ts
│ │ │ │ ├── InputSource.ts
│ │ │ │ ├── LavfiVideoInputSource.ts
│ │ │ │ ├── SubtitlesInputSource.ts
│ │ │ │ ├── VideoInputSource.ts
│ │ │ │ └── WatermarkInputSource.ts
│ │ │ ├── options
│ │ │ │ ├── AudioOutputOptions.ts
│ │ │ │ ├── EnvironmentVariables.ts
│ │ │ │ ├── GlobalOption.ts
│ │ │ │ ├── HlsConcatOutputFormat.ts
│ │ │ │ ├── HlsOutputFormat.ts
│ │ │ │ ├── KnownFfmpegOptions.ts
│ │ │ │ ├── LogLevelOption.ts
│ │ │ │ ├── NoStatsOption.ts
│ │ │ │ ├── OutputOption.ts
│ │ │ │ ├── hardwareAcceleration
│ │ │ │ │ ├── ExtraHardwareFramesOption.ts
│ │ │ │ │ ├── NvidiaOptions.ts
│ │ │ │ │ ├── QsvOptions.ts
│ │ │ │ │ └── VaapiOptions.ts
│ │ │ │ └── input
│ │ │ │ │ ├── ConcatHttpReconnectOptions.ts
│ │ │ │ │ ├── ConcatInputFormatOption.ts
│ │ │ │ │ ├── CopyTimestampInputOption.ts
│ │ │ │ │ ├── DoNotIgnoreLoopInputOption.ts
│ │ │ │ │ ├── HttpHeadersInputOption.ts
│ │ │ │ │ ├── HttpReconnectOptions.ts
│ │ │ │ │ ├── InfiniteLoopInputOption.ts
│ │ │ │ │ ├── InputOption.ts
│ │ │ │ │ ├── LavfiInputOption.ts
│ │ │ │ │ ├── ReadrateInputOption.ts
│ │ │ │ │ ├── StreamSeekInputOption.ts
│ │ │ │ │ └── UserAgentInputOption.ts
│ │ │ ├── pipeline
│ │ │ │ ├── BasePipelineBuilder.ts
│ │ │ │ ├── Pipeline.ts
│ │ │ │ ├── PipelineBuilder.ts
│ │ │ │ ├── PipelineBuilderFactory.ts
│ │ │ │ ├── PipelineInputs.ts
│ │ │ │ ├── hardware
│ │ │ │ │ ├── NvidiaPipelineBuilder.test.ts
│ │ │ │ │ ├── NvidiaPipelineBuilder.ts
│ │ │ │ │ ├── QsvPipelineBuilder.ts
│ │ │ │ │ ├── VaapiPipelineBuilder.test.ts
│ │ │ │ │ ├── VaapiPipelineBuilder.ts
│ │ │ │ │ └── VideoToolboxPipelineBuilder.ts
│ │ │ │ └── software
│ │ │ │ │ └── SoftwarePipelineBuilder.ts
│ │ │ ├── state
│ │ │ │ ├── AudioState.ts
│ │ │ │ ├── FfmpegState.ts
│ │ │ │ └── FrameState.ts
│ │ │ ├── types.ts
│ │ │ └── types
│ │ │ │ ├── FrameSize.ts
│ │ │ │ └── PipelineStep.ts
│ │ ├── ffmpeg.ts
│ │ ├── ffmpegBase.ts
│ │ ├── ffmpegInfo.ts
│ │ └── ffmpegText.ts
│ ├── globals.ts
│ ├── index.ts
│ ├── interfaces
│ │ └── ITimer.ts
│ ├── migration
│ │ ├── DirectMigrationProvider.ts
│ │ ├── Migration.ts
│ │ ├── db
│ │ │ ├── DatabaseCopyMigrator.ts
│ │ │ ├── LegacyMigration0.ts
│ │ │ ├── LegacyMigration1.ts
│ │ │ ├── LegacyMigration10.ts
│ │ │ ├── LegacyMigration11.ts
│ │ │ ├── LegacyMigration12.ts
│ │ │ ├── LegacyMigration13.ts
│ │ │ ├── LegacyMigration14.ts
│ │ │ ├── LegacyMigration15.ts
│ │ │ ├── LegacyMigration16.ts
│ │ │ ├── LegacyMigration2.ts
│ │ │ ├── LegacyMigration3.ts
│ │ │ ├── LegacyMigration4.ts
│ │ │ ├── LegacyMigration5.ts
│ │ │ ├── LegacyMigration6.ts
│ │ │ ├── LegacyMigration7.ts
│ │ │ ├── LegacyMigration8.ts
│ │ │ ├── LegacyMigration9.ts
│ │ │ ├── Migration1730806741.ts
│ │ │ ├── Migration1731982492.ts
│ │ │ ├── Migration1732969335_AddTranscodeConfig.ts
│ │ │ ├── Migration1735044379_AddHlsDirect.ts
│ │ │ ├── Migration1738604866_AddEmby.ts
│ │ │ ├── Migration1740691984_ProgramMediaSourceId.ts
│ │ │ ├── Migration1741297998_AddProgramIndexes.ts
│ │ │ ├── Migration1741658292_MediaSourceIndex.ts
│ │ │ ├── Migration1744918641_AddMediaSourceUserInfo.ts
│ │ │ ├── Migration1745007030_ReaddMissingProgramExternalIdIndexes.ts
│ │ │ ├── Migration1746042667_AddSubtitles.ts
│ │ │ ├── Migration1746123876_ReworkSubtitleFilter.ts
│ │ │ ├── Migration1746128022_FixSubtitlePriorityType.ts
│ │ │ ├── Migration1747248401_ChangeSubtitlePriorityType.ts
│ │ │ ├── Migration1748345299_AddMoreProgramTypes.ts
│ │ │ └── util.ts
│ │ ├── legacy_migration
│ │ │ ├── LegacyChannelMigrator.ts
│ │ │ ├── legacyDbMigration.test.ts
│ │ │ ├── legacyDbMigration.ts
│ │ │ ├── libraryMigrator.ts
│ │ │ ├── metadataBackfill.ts
│ │ │ └── migrationUtil.ts
│ │ └── lineups
│ │ │ ├── ChannelLineupMigration.ts
│ │ │ ├── ChannelLineupMigrator.ts
│ │ │ ├── RandomSlotDurationSpecMigration.ts
│ │ │ └── SlotShowIdMigration.ts
│ ├── resources
│ │ ├── images
│ │ │ ├── favicon.ico
│ │ │ ├── favicon.svg
│ │ │ ├── generic-error-screen.png
│ │ │ ├── generic-music-screen.png
│ │ │ ├── generic-offline-screen.png
│ │ │ ├── loading-screen.png
│ │ │ └── tunarr.png
│ │ └── test
│ │ │ └── legacy-migration
│ │ │ ├── channels
│ │ │ └── 1.json
│ │ │ └── custom-shows
│ │ │ └── e2920853-6024-4360-934d-4fd8d582f3e3.json
│ ├── services
│ │ ├── BestFitFillerPicker.ts
│ │ ├── EventService.ts
│ │ ├── FileCacheService.ts
│ │ ├── FileSystemService.ts
│ │ ├── FillerPicker.ts
│ │ ├── HDHRService.ts
│ │ ├── HealthCheckService.ts
│ │ ├── M3UService.ts
│ │ ├── OnDemandChannelService.ts
│ │ ├── PlexItemEnumerator.ts
│ │ ├── Scheduler.ts
│ │ ├── StartupService.ts
│ │ ├── SystemDevicesService.ts
│ │ ├── TvGuideService.ts
│ │ ├── XmlTvWriter.ts
│ │ ├── cacheImageService.ts
│ │ ├── dynamic_channels
│ │ │ ├── CollapseOfflineTimeOperator.test.ts
│ │ │ ├── CollapseOfflineTimeOperator.ts
│ │ │ ├── CompositeOperator.ts
│ │ │ ├── ContentSourceUpdater.ts
│ │ │ ├── DynamicChannelsModule.ts
│ │ │ ├── IntermediateOperator.ts
│ │ │ ├── LineupCreator.ts
│ │ │ ├── LineupCreatorContext.ts
│ │ │ ├── PadProgramsSchedulingOperator.ts
│ │ │ ├── PlexContentSourceUpdater.ts
│ │ │ ├── RandomSortOperator.ts
│ │ │ ├── ReleaseDateSortOperator.ts
│ │ │ ├── ScheduledRedirectOperator.ts
│ │ │ ├── SchedulingOperator.ts
│ │ │ └── SchedulingOperatorFactory.ts
│ │ ├── health_checks
│ │ │ ├── FfmpegDebugLoggingHealthCheck.ts
│ │ │ ├── FfmpegTranscodeDirectoryHealthCheck.ts
│ │ │ ├── FfmpegVersionHealthCheck.ts
│ │ │ ├── HardwareAccelerationHealthCheck.ts
│ │ │ ├── HealthCheck.ts
│ │ │ ├── HealthCheckModule.ts
│ │ │ ├── MissingProgramAssociationsHealthCheck.ts
│ │ │ └── MissingSeasonNumbersHealthCheck.ts
│ │ └── interfaces
│ │ │ ├── IFillerPicker.ts
│ │ │ └── IOnDemandChannelService.ts
│ ├── stream
│ │ ├── ChannelCache.ts
│ │ ├── ConcatSession.ts
│ │ ├── ConcatStream.ts
│ │ ├── ConnectionTracker.ts
│ │ ├── DirectStreamSession.ts
│ │ ├── ExternalSubtitleDownloader.ts
│ │ ├── OfflineProgramStream.ts
│ │ ├── PlayerStreamContext.ts
│ │ ├── ProgramStream.ts
│ │ ├── ProgramStreamFactory.ts
│ │ ├── Session.ts
│ │ ├── SessionManager.ts
│ │ ├── StreamDetailsFetcher.ts
│ │ ├── StreamModule.ts
│ │ ├── StreamProgramCalculator.ts
│ │ ├── StreamThrottler.ts
│ │ ├── VideoStream.ts
│ │ ├── emby
│ │ │ ├── EmbyProgramStream.ts
│ │ │ └── EmbyStreamDetails.ts
│ │ ├── hls
│ │ │ ├── BaseHlsSession.ts
│ │ │ ├── HlsPlaylistCreator.ts
│ │ │ ├── HlsPlaylistMutator.test.ts
│ │ │ ├── HlsPlaylistMutator.ts
│ │ │ ├── HlsSession.ts
│ │ │ └── HlsSlowerSession.ts
│ │ ├── jellyfin
│ │ │ ├── JellyfinProgramStream.ts
│ │ │ └── JellyfinStreamDetails.ts
│ │ ├── local
│ │ │ └── LocalFileStreamDetails.ts
│ │ ├── plex
│ │ │ ├── PlexProgramStream.ts
│ │ │ └── PlexStreamDetails.ts
│ │ ├── types.ts
│ │ └── util.ts
│ ├── tasks
│ │ ├── BackupTask.ts
│ │ ├── CleanupSessionsTask.ts
│ │ ├── DynamicChannelUpdaterFactory.ts
│ │ ├── OnDemandChannelStateTask.ts
│ │ ├── OneOffTask.ts
│ │ ├── ReconcileProgramDurationsTask.ts
│ │ ├── ScheduleDynamicChannelsTask.ts
│ │ ├── ScheduledTask.ts
│ │ ├── SubtitleExtractorTask.ts
│ │ ├── Task.ts
│ │ ├── TaskQueue.ts
│ │ ├── TasksModule.ts
│ │ ├── UpdateXmlTvTask.ts
│ │ ├── fixers
│ │ │ ├── BackfillMediaSourceIdFixer.ts
│ │ │ ├── BackfillProgramExternalIds.ts
│ │ │ ├── EnsureTranscodeConfigIds.ts
│ │ │ ├── FixerModule.ts
│ │ │ ├── FixerRunner.ts
│ │ │ ├── addPlexServerIds.ts
│ │ │ ├── backfillProgramGroupings.ts
│ │ │ ├── fixer.ts
│ │ │ └── missingSeasonNumbersFixer.ts
│ │ ├── jellyfin
│ │ │ ├── SaveJellyfinProgramExternalIdsTask.ts
│ │ │ └── UpdateJellyfinPlayStatusTask.ts
│ │ └── plex
│ │ │ ├── SavePlexProgramExternalIdsTask.ts
│ │ │ └── UpdatePlexPlayStatusTask.ts
│ ├── testing
│ │ ├── bun-test.d.ts
│ │ ├── getFakeSettingsDb.ts
│ │ ├── matchers
│ │ │ └── PixelFormatMatcher.ts
│ │ ├── resources
│ │ │ ├── test.m3u8
│ │ │ ├── vainfo-10500.txt
│ │ │ └── vainfo-13500.txt
│ │ └── vitest.d.ts
│ ├── types
│ │ ├── DateTimeRange.ts
│ │ ├── OpenDateTimeRange.ts
│ │ ├── cron-parser.d.ts
│ │ ├── errors.ts
│ │ ├── eventEmitter.ts
│ │ ├── fastify.d.ts
│ │ ├── ffmpeg.ts
│ │ ├── func.ts
│ │ ├── inject.ts
│ │ ├── internal.ts
│ │ ├── node-ssdp.d.ts
│ │ ├── path.ts
│ │ ├── plexApiTypes.ts
│ │ ├── result.ts
│ │ ├── schemas.ts
│ │ ├── serverType.d.ts
│ │ └── util.ts
│ └── util
│ │ ├── ChildProcessHelper.ts
│ │ ├── Timer.ts
│ │ ├── asyncPool.ts
│ │ ├── axios.ts
│ │ ├── binarySearch.test.ts
│ │ ├── binarySearch.ts
│ │ ├── cache.ts
│ │ ├── channels.ts
│ │ ├── constants.ts
│ │ ├── containerUtil.ts
│ │ ├── databaseDirectoryUtil.ts
│ │ ├── dayjs.ts
│ │ ├── debug.ts
│ │ ├── defaults.ts
│ │ ├── enumUtil.ts
│ │ ├── env.ts
│ │ ├── externalIds.ts
│ │ ├── fsUtil.ts
│ │ ├── index.ts
│ │ ├── inject.ts
│ │ ├── lodash-mixins.ts
│ │ ├── logging
│ │ ├── LoggerFactory.ts
│ │ └── basicPrettyTransport.ts
│ │ ├── mutexMap.ts
│ │ ├── perf.ts
│ │ ├── random.ts
│ │ ├── scheduleTimeSlotsWorker.ts
│ │ ├── schedulingUtil.test.ts
│ │ ├── schedulingUtil.ts
│ │ ├── serverUtil.ts
│ │ ├── sqliteUtil.ts
│ │ ├── streams.ts
│ │ ├── strings.test.ts
│ │ ├── strings.ts
│ │ ├── subtitles.ts
│ │ ├── throttle.ts
│ │ ├── util.test.ts
│ │ ├── version.ts
│ │ └── zod.ts
├── svg
│ ├── generic-error-screen.svg
│ ├── generic-music-screen.svg
│ ├── generic-offline.screen.svg
│ └── loading-screen.svg
├── tests
│ ├── server.test.ts
│ ├── testServer.ts
│ └── vitest.d.ts
├── tsconfig.build.json
├── tsconfig.json
├── turbo.json
└── vitest.config.ts
├── shared
├── package.json
├── src
│ ├── index.ts
│ ├── services
│ │ ├── ApiProgramMinter.test.ts
│ │ ├── ApiProgramMinter.ts
│ │ ├── RandomSlotsService.ts
│ │ ├── TimeSlotService.ts
│ │ └── randomSlotsService.test.ts
│ ├── types
│ │ └── index.ts
│ └── util
│ │ ├── ProgramIterator.ts
│ │ ├── constants.ts
│ │ ├── dayjsExtensions.test.ts
│ │ ├── dayjsExtensions.ts
│ │ ├── debug.ts
│ │ ├── env.ts
│ │ ├── index.ts
│ │ ├── plexSearchUtil.ts
│ │ ├── plexUtil.ts
│ │ ├── seq.ts
│ │ └── slotSchedulerUtil.ts
├── tsconfig.json
├── tsconfig.prod.json
├── tsup.config.ts
├── turbo.json
└── vitest.config.ts
├── turbo.json
├── types
├── package.json
├── src
│ ├── Channel.ts
│ ├── CustomShow.ts
│ ├── Event.ts
│ ├── FfmpegSettings.ts
│ ├── FillerList.ts
│ ├── GuideApi.ts
│ ├── HdhrSettings.ts
│ ├── LanguagePreferences.ts
│ ├── MediaSourceSettings.ts
│ ├── Program.ts
│ ├── Subtitles.ts
│ ├── SystemSettings.ts
│ ├── Tasks.ts
│ ├── Theme.ts
│ ├── TranscodeConfig.ts
│ ├── XmlTvSettings.ts
│ ├── api
│ │ ├── Scheduling.ts
│ │ ├── index.ts
│ │ └── plexSearch.ts
│ ├── emby
│ │ └── index.ts
│ ├── index.ts
│ ├── jellyfin
│ │ └── index.ts
│ ├── misc.ts
│ ├── plex
│ │ ├── dvr.ts
│ │ └── index.ts
│ ├── schemas
│ │ ├── channelSchema.ts
│ │ ├── customShowsSchema.ts
│ │ ├── eventSchema.ts
│ │ ├── fillerSchema.ts
│ │ ├── guideApiSchemas.ts
│ │ ├── index.ts
│ │ ├── miscSchemas.ts
│ │ ├── programmingSchema.ts
│ │ ├── settingsSchemas.ts
│ │ ├── subtitleSchema.ts
│ │ ├── tasksSchema.ts
│ │ ├── themeSchema.ts
│ │ ├── transcodeConfigSchemas.ts
│ │ ├── util.ts
│ │ └── utilSchemas.ts
│ └── util.ts
├── tsconfig.json
└── tsup.config.ts
└── web
├── .gitignore
├── index.html
├── package.json
├── public
├── favicon.svg
└── tunarr.png
├── src
├── App.css
├── App.tsx
├── Tunarr.tsx
├── assets
│ ├── CHANGELOG.md
│ ├── emby.png
│ ├── emby.svg
│ ├── error_this_is_fine.png
│ ├── error_this_is_fire.png
│ ├── icon_clyde_black_RGB.svg
│ ├── jellyfin.svg
│ ├── plex.svg
│ └── react.svg
├── components
│ ├── BottomNavBar.tsx
│ ├── Breadcrumbs.tsx
│ ├── DeleteConfirmationDialog.tsx
│ ├── Drawer.tsx
│ ├── GridContainerTabPanel.tsx
│ ├── InlineModal.tsx
│ ├── LanguageAutocomplete.tsx
│ ├── LanguagePreferencesList.tsx
│ ├── ProgramDetailsDialog.tsx
│ ├── TabPanel.tsx
│ ├── TunarrLogo.tsx
│ ├── VersionFooter.tsx
│ ├── Video.tsx
│ ├── base
│ │ ├── ElevatedTooltip.tsx
│ │ ├── LoadingIcon.tsx
│ │ ├── PaddedPaper.tsx
│ │ ├── ProgramViewToggleButton.tsx
│ │ ├── StandaloneToggleButton.tsx
│ │ └── StyledMenu.tsx
│ ├── channel_config
│ │ ├── AddProgrammingButton.tsx
│ │ ├── AddSelectedMediaButton.tsx
│ │ ├── AlphanumericFilters.tsx
│ │ ├── ChannelEditActions.tsx
│ │ ├── ChannelEpgConfig.tsx
│ │ ├── ChannelFlexConfig.tsx
│ │ ├── ChannelProgrammingConfig.tsx
│ │ ├── ChannelProgrammingDeleteOptions.tsx
│ │ ├── ChannelProgrammingList.tsx
│ │ ├── ChannelProgrammingOrganizeOptions.tsx
│ │ ├── ChannelProgrammingSort.tsx
│ │ ├── ChannelProgrammingTools.tsx
│ │ ├── ChannelPropertiesEditor.tsx
│ │ ├── ChannelSubtitlePreferencesTable.tsx
│ │ ├── ChannelTranscodingConfig.tsx
│ │ ├── CustomShowProgrammingSelector.tsx
│ │ ├── EditChannelForm.tsx
│ │ ├── EditChannelTabPanel.tsx
│ │ ├── MediaGridItem.tsx
│ │ ├── MediaItemGrid.tsx
│ │ ├── MediaItemList.tsx
│ │ ├── NoChannelsCreated.tsx
│ │ ├── ProgrammingSelector.tsx
│ │ ├── SelectedProgrammingActions.tsx
│ │ ├── SelectedProgrammingList.tsx
│ │ ├── emby
│ │ │ ├── EmbyGridItem.tsx
│ │ │ ├── EmbyListItem.tsx
│ │ │ ├── EmbyListViewBreadcrumbs.tsx
│ │ │ ├── EmbyProgramGrid.tsx
│ │ │ └── EmbyProgrammingSelector.tsx
│ │ ├── jellyfin
│ │ │ ├── JellyfinGridItem.tsx
│ │ │ ├── JellyfinListItem.tsx
│ │ │ ├── JellyfinListViewBreadcrumbs.tsx
│ │ │ ├── JellyfinProgramGrid.tsx
│ │ │ └── JellyfinProgrammingSelector.tsx
│ │ └── plex
│ │ │ ├── PlexFilterBuilder.tsx
│ │ │ ├── PlexGridItem.tsx
│ │ │ ├── PlexListItem.tsx
│ │ │ ├── PlexListViewBreadcrumbs.tsx
│ │ │ ├── PlexProgrammingFilterToolbar.tsx
│ │ │ ├── PlexProgrammingGridView.tsx
│ │ │ ├── PlexProgrammingListView.tsx
│ │ │ ├── PlexProgrammingSelector.tsx
│ │ │ └── PlexSortField.tsx
│ ├── channels
│ │ ├── ChannelDeleteDialog.tsx
│ │ ├── ChannelSessionsDialog.tsx
│ │ └── ChannelsTableOptionsMenu.tsx
│ ├── custom-shows
│ │ ├── CustomShowSortToolsMenu.tsx
│ │ └── EditCustomShowForm.tsx
│ ├── filler
│ │ └── EditFillerListForm.tsx
│ ├── guide
│ │ └── TvGuide.tsx
│ ├── programming_controls
│ │ ├── AddBlockShuffleModal.tsx
│ │ ├── AddBreaksModal.tsx
│ │ ├── AddFlexModal.tsx
│ │ ├── AddPaddingModal.tsx
│ │ ├── AddRedirectModal.tsx
│ │ ├── AddReplicateModal.tsx
│ │ ├── AddRerunBlockModal.tsx
│ │ ├── AddRestrictHoursModal.tsx
│ │ ├── AdjustWeightsModal.tsx
│ │ ├── BalanceProgrammingModal.tsx
│ │ └── RemoveShowsModal.tsx
│ ├── server_events
│ │ ├── ServerEventsContext.ts
│ │ └── ServerEventsProvider.tsx
│ ├── settings
│ │ ├── AddPlexServer.tsx
│ │ ├── ConnectMediaSources.tsx
│ │ ├── DarkModeButton.tsx
│ │ ├── ImageUploadInput.tsx
│ │ ├── UnsavedNavigationAlert.tsx
│ │ ├── ffmpeg
│ │ │ ├── TranscodeConfigSettingsForm.tsx
│ │ │ └── TranscodeConfigsTable.tsx
│ │ ├── general
│ │ │ ├── GeneralSettingsForm.tsx
│ │ │ └── WebSettings.tsx
│ │ └── media_source
│ │ │ ├── AddMediaSourceButton.tsx
│ │ │ ├── EmbyServerEditDialog.tsx
│ │ │ ├── JelllyfinServerEditDialog.tsx
│ │ │ ├── MediaSourceDeleteDialog.tsx
│ │ │ ├── MediaSourceTableRow.tsx
│ │ │ └── PlexServerEditDialog.tsx
│ ├── slot_scheduler
│ │ ├── AddRandomSlotButton.tsx
│ │ ├── AddTimeSlotButton.tsx
│ │ ├── ClearSlotsButton.tsx
│ │ ├── EditRandomSlotDialogContent.tsx
│ │ ├── EditSlotProgrammingForm.tsx
│ │ ├── EditTimeSlotDialogContent.tsx
│ │ ├── MissingProgramsAlert.tsx
│ │ ├── ProgramCalendarView.tsx
│ │ ├── ProgramDayCalendarView.tsx
│ │ ├── ProgramWeekCalendarView.tsx
│ │ ├── RandomSlotFormProvider.tsx
│ │ ├── RandomSlotPresetButton.tsx
│ │ ├── RandomSlotSettingsForm.tsx
│ │ ├── RandomSlotTable.tsx
│ │ ├── RandomSlotWarningsDialog.tsx
│ │ ├── RandomSlotsWeightAdjustDialog.tsx
│ │ ├── SlotProgrammingTooLongWarningDetails.tsx
│ │ ├── SlotTypes.ts
│ │ ├── TimeSlotFormProvider.tsx
│ │ ├── TimeSlotTable.tsx
│ │ └── TimeSlotWarningsDialog.tsx
│ └── util
│ │ └── TypedController.tsx
├── context
│ ├── ProgrammingSelectionContext.ts
│ └── TunarrApiContext.tsx
├── dev
│ ├── ProgramDebugDetailsMenu.tsx
│ └── TanStackRouterDevtools.tsx
├── external
│ ├── api.ts
│ ├── embyApi.ts
│ ├── ffmpegApi.ts
│ ├── jellyfinApi.ts
│ └── settingsApi.ts
├── generated
│ └── .gitkeep
├── helpers
│ ├── AsyncInterval.ts
│ ├── DropdownOption.d.ts
│ ├── colors.ts
│ ├── constants.ts
│ ├── dayjs.ts
│ ├── embyUtil.ts
│ ├── formatters.ts
│ ├── inlineModalUtil.ts
│ ├── jellyfinUtil.ts
│ ├── language.ts
│ ├── plexLogin.ts
│ ├── plexSearchUtil.ts
│ ├── plexUtil.ts
│ ├── programUtil.ts
│ ├── random.ts
│ ├── routeLoaders.ts
│ ├── slotSchedulerUtil.ts
│ └── util.ts
├── hooks
│ ├── calendarHooks.ts
│ ├── channel_config
│ │ └── useProgramHierarchy.ts
│ ├── colorHooks.ts
│ ├── emby
│ │ ├── useEmbyApi.ts
│ │ └── useEmbyLogin.ts
│ ├── jellyfin
│ │ ├── jellyfinHookUtil.ts
│ │ ├── useEmbyLogin.ts
│ │ ├── useJellyfinApi.ts
│ │ ├── useJellyfinBackendStatus.ts
│ │ └── useJellyfinLogin.ts
│ ├── media-sources
│ │ └── useMediaSourceBackendStatus.ts
│ ├── plex
│ │ ├── plexHookUtil.ts
│ │ ├── usePlex.ts
│ │ ├── usePlexCollections.ts
│ │ ├── usePlexFilters.ts
│ │ ├── usePlexLogin.tsx
│ │ ├── usePlexPlaylists.ts
│ │ ├── usePlexSearch.ts
│ │ ├── usePlexServerStatus.ts
│ │ └── usePlexTags.ts
│ ├── programming_controls
│ │ ├── useAddBreaks.ts
│ │ ├── useAddProgramming.ts
│ │ ├── useAlphaSort.ts
│ │ ├── useBalancePrograms.ts
│ │ ├── useBlockShuffle.ts
│ │ ├── useConcolidatePrograms.ts
│ │ ├── useCyclicShuffle.ts
│ │ ├── useEpisodeNumberSort.ts
│ │ ├── usePadStartTimes.ts
│ │ ├── useRandomSort.ts
│ │ ├── useReleaseDateSort.test.ts
│ │ ├── useReleaseDateSort.ts
│ │ ├── useRemoveAllProgramming.ts
│ │ ├── useRemoveDuplicates.ts
│ │ ├── useRemoveFlex.ts
│ │ ├── useRemoveProgramming.ts
│ │ ├── useRemoveSpecials.ts
│ │ ├── useReplicatePrograms.ts
│ │ ├── useRestrictHours.ts
│ │ ├── useSlideSchedule.ts
│ │ └── useSlotProgramOptions.ts
│ ├── settingsHooks.ts
│ ├── slot_scheduler
│ │ ├── useAdjustRandomSlotWeights.ts
│ │ ├── useCalculatorProgramFrequency.ts
│ │ └── useScheduledSlotProgramDetails.ts
│ ├── useApiQuery.ts
│ ├── useAsyncInterval.ts
│ ├── useBrowserInfo.ts
│ ├── useChannelFormContext.ts
│ ├── useChannelLineup.ts
│ ├── useChannels.ts
│ ├── useCopyToClipboard.ts
│ ├── useCreateChannel.ts
│ ├── useCustomShows.ts
│ ├── useDayjs.ts
│ ├── useDebouncedState.ts
│ ├── useDownload.ts
│ ├── useFillerLists.ts
│ ├── useHls.ts
│ ├── useM3ULink.ts
│ ├── useNavItems.tsx
│ ├── useNumberString.ts
│ ├── useProgramTitleFormatter.ts
│ ├── useProgrammingSelectionContext.ts
│ ├── useQueryHelpers.ts
│ ├── useRandomSlotFormContext.ts
│ ├── useRouteName.ts
│ ├── useServerEvents.ts
│ ├── useSuspendedStore.ts
│ ├── useSystemHealthChecks.ts
│ ├── useSystemSettings.ts
│ ├── useTimeSlotFormContext.ts
│ ├── useTunarrApi.ts
│ ├── useTunarrTheme.ts
│ ├── useTvGuide.ts
│ ├── useUpdateChannel.ts
│ ├── useUpdateLineup.ts
│ ├── useVersion.tsx
│ └── useXmlTvLink.ts
├── index.css
├── main.tsx
├── pages
│ ├── ErrorPage.tsx
│ ├── channels
│ │ ├── ChannelProgrammingPage.tsx
│ │ ├── ChannelsPage.tsx
│ │ ├── EditChannelPage.tsx
│ │ ├── NewChannelPage.tsx
│ │ ├── ProgrammingSelectorPage.tsx
│ │ ├── RandomSlotEditorPage.tsx
│ │ └── TimeSlotEditorPage.tsx
│ ├── guide
│ │ └── GuidePage.tsx
│ ├── library
│ │ ├── CustomShowsPage.tsx
│ │ ├── EditCustomShowPage.tsx
│ │ ├── EditFillerPage.tsx
│ │ ├── FillerListsPage.tsx
│ │ ├── LibraryIndexPage.tsx
│ │ ├── NewCustomShowPage.tsx
│ │ └── NewFillerPage.tsx
│ ├── settings
│ │ ├── EditTranscodeConfigSettingsPage.tsx
│ │ ├── FfmpegSettingsPage.tsx
│ │ ├── GeneralSettingsPage.tsx
│ │ ├── HdhrSettingsPage.tsx
│ │ ├── MediaSourceSettingsPage.tsx
│ │ ├── NewTranscodeConfigSettingsPage.tsx
│ │ ├── SettingsLayout.tsx
│ │ ├── TaskSettingsPage.tsx
│ │ └── XmlTvSettingsPage.tsx
│ ├── system
│ │ ├── StatusPage.tsx
│ │ ├── SystemDebugPage.tsx
│ │ ├── SystemLayout.tsx
│ │ └── SystemLogsPage.tsx
│ ├── watch
│ │ └── ChannelWatchPage.tsx
│ └── welcome
│ │ └── WelcomePage.tsx
├── providers
│ └── DayjsProvider.tsx
├── queryClient.ts
├── routeTree.gen.ts
├── routes
│ ├── __root.tsx
│ ├── channels
│ │ ├── $channelId.tsx
│ │ ├── index.tsx
│ │ ├── new.tsx
│ │ └── test.tsx
│ ├── channels_
│ │ └── $channelId
│ │ │ ├── edit
│ │ │ └── index.tsx
│ │ │ ├── programming
│ │ │ ├── add.tsx
│ │ │ ├── index.tsx
│ │ │ ├── slot-editor.tsx
│ │ │ └── time-slot-editor.tsx
│ │ │ └── watch.tsx
│ ├── guide.tsx
│ ├── index.tsx
│ ├── library
│ │ ├── custom-shows.tsx
│ │ ├── custom-shows_.new.tsx
│ │ ├── custom-shows_.new_.programming.tsx
│ │ ├── custom-shows_
│ │ │ └── $showId
│ │ │ │ ├── edit.tsx
│ │ │ │ └── programming.tsx
│ │ ├── fillers.tsx
│ │ ├── fillers_.new.tsx
│ │ ├── fillers_.new_.programming.tsx
│ │ ├── fillers_
│ │ │ └── $fillerId
│ │ │ │ ├── edit.tsx
│ │ │ │ └── programming.tsx
│ │ └── index.tsx
│ ├── settings.tsx
│ ├── settings
│ │ ├── ffmpeg.tsx
│ │ ├── ffmpeg_
│ │ │ ├── $configId.tsx
│ │ │ └── new.tsx
│ │ ├── general.tsx
│ │ ├── hdhr.tsx
│ │ ├── sources.tsx
│ │ ├── tasks.tsx
│ │ └── xmltv.tsx
│ ├── system.tsx
│ ├── system
│ │ ├── debug.tsx
│ │ ├── index.tsx
│ │ └── logs.tsx
│ └── welcome.tsx
├── store
│ ├── channelEditor
│ │ ├── actions.ts
│ │ └── store.ts
│ ├── customShowEditor
│ │ ├── actions.ts
│ │ └── store.ts
│ ├── entityEditor
│ │ └── util.ts
│ ├── fillerListEditor
│ │ └── action.ts
│ ├── index.ts
│ ├── plexMetadata
│ │ ├── actions.ts
│ │ └── store.ts
│ ├── programmingSelector
│ │ ├── KnownMedia.ts
│ │ ├── actions.ts
│ │ ├── selectors.ts
│ │ └── store.ts
│ ├── selectors.ts
│ ├── settings
│ │ ├── actions.ts
│ │ ├── selectors.ts
│ │ └── store.ts
│ └── themeEditor
│ │ ├── actions.ts
│ │ └── store.ts
├── strings.ts
├── types
│ ├── MediaSource.d.ts
│ ├── RouterContext.ts
│ ├── dom.ts
│ ├── env.d.ts
│ ├── index.ts
│ └── util.ts
└── vite-env.d.ts
├── tsconfig.build.json
├── tsconfig.json
├── tsconfig.node.json
├── turbo.json
├── vite.config.ts
└── vitest.config.ts
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | *Dockerfile
4 | .dockerignore
5 | .git
6 | .gitignore
7 | bin
8 | */*/dist*
9 | */*/build*
10 |
11 | .pseudotv
12 | .dizquetv
13 | .tunarr
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: chrisbenincasa
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/workflows/commitlint.yml:
--------------------------------------------------------------------------------
1 | name: Lint Commit Messages
2 | on: [pull_request]
3 |
4 | permissions:
5 | contents: read
6 | pull-requests: read
7 |
8 | jobs:
9 | commitlint:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v3
13 | - uses: wagoid/commitlint-github-action@v6
14 |
--------------------------------------------------------------------------------
/.github/workflows/release-please-prerelease.yml:
--------------------------------------------------------------------------------
1 | on:
2 | workflow_dispatch:
3 | push:
4 | branches:
5 | - media-scanner
6 |
7 | permissions:
8 | contents: write
9 | pull-requests: write
10 |
11 | name: Create Release
12 |
13 | jobs:
14 | release-please:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: googleapis/release-please-action@v4
18 | id: release
19 | with:
20 | token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
21 | config-file: release-please-config.json
22 | manifest-file: .release-please-manifest.json
23 | target-branch: ${{ github.ref_name }}
24 |
--------------------------------------------------------------------------------
/.github/workflows/release-please.yml:
--------------------------------------------------------------------------------
1 | on:
2 | workflow_dispatch:
3 | push:
4 | branches:
5 | - dev
6 |
7 | permissions:
8 | contents: write
9 | pull-requests: write
10 |
11 | name: Create Release
12 |
13 | jobs:
14 | release-please:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: googleapis/release-please-action@v4
18 | id: release
19 | with:
20 | token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
21 | config-file: release-please-config.json
22 | manifest-file: .release-please-manifest.json
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | bin/
4 | .pseudotv/
5 | .dizquetv*/
6 | .tunarr*/
7 | web/public/bundle.js
8 | *.orig
9 | package-lock.json
10 |
11 | config/
12 | .vscode/
13 | build/
14 | log.txt
15 | *.db
16 | .DS_Store
17 | .turbo/
18 | *.env*
19 | *.tar*
20 | coverage/
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | pnpm commitlint --edit $1
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | pnpm lint-staged
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .github/
2 | .husky/
3 | docker/
4 | docs/
5 | resources/
6 |
7 | pnpm-*.yaml
8 |
9 | **/bundle
10 | **/node_modules
11 |
12 | .release-please-manifest.json
13 | CHANGELOG.md
14 | mkdocs.yml
15 |
16 | server/streams/**/*.ts
17 | server/web/
18 | server/temp
19 | **/.tsup
20 |
21 | *.html
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all",
4 | "overrides": [
5 | {
6 | "files": ["*.ts", "*.mts", "*.tsx"],
7 | "excludeFiles": ["server/src/migrations/**/*.ts"],
8 | "options": {
9 | "parser": "typescript"
10 | }
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/.release-please-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | ".": "0.20.1"
3 | }
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Tunarr
2 |
3 | ## Setting up the dev environment
4 |
5 | ## Coding Standard
6 |
7 | ##
8 |
--------------------------------------------------------------------------------
/commitlint.config.ts:
--------------------------------------------------------------------------------
1 | import type { UserConfig } from '@commitlint/types';
2 |
3 | const Config: UserConfig = {
4 | extends: ['@commitlint/config-conventional'],
5 | };
6 |
7 | export default Config;
8 |
--------------------------------------------------------------------------------
/design/error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/design/error.png
--------------------------------------------------------------------------------
/design/tunarr-channels.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/design/tunarr-channels.png
--------------------------------------------------------------------------------
/design/tunarr-guide.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/design/tunarr-guide.png
--------------------------------------------------------------------------------
/docker/dev.compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | tunarr:
3 | build:
4 | context: ../
5 | target: dev
6 | dockerfile: ./nvidia.Dockerfile
7 | ports:
8 | - '5173:5173'
9 | - '8000:8000'
10 | user: $USER:$GID
11 | runtime: nvidia
12 | volumes:
13 | - ../:/tunarr
14 |
--------------------------------------------------------------------------------
/docker/example.compose.yaml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 | services:
3 | tunarr:
4 | # image: chrisbenincasa/tunarr:edge
5 | # Uncomment along with runtime below to enable HW accel
6 | image: chrisbenincasa/tunarr:edge-nvidia
7 | container_name: tunarr
8 | ports:
9 | - 8088:8000
10 | # Uncomment if using the Nvidia container
11 | runtime: nvidia
12 | environment:
13 | - LOG_LEVEL=${TUNARR_LOG_LEVEL:-INFO}
14 | # volumes:
15 | # The host path is relative to the location of the compose file
16 | # This can also use an absolute path.
17 | #
18 | # Uncomment if migrating from dizquetv. Chnage the host path
19 | # to the location of your dizquetv "database"
20 | # - ./.dizquetv:/.dizquetv
--------------------------------------------------------------------------------
/docker/nexe-windows.Dockerfile:
--------------------------------------------------------------------------------
1 | FROM mcr.microsoft.com/windows/nanoserver:ltsc2022 as base
2 |
3 | # installs Chocolatey (Windows Package Manager)
4 | RUN Set-ExecutionPolicy Bypass -Scope Process -Force;
5 | RUN [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072;
6 | RUN iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'));
7 |
8 | # download and install Node.js
9 | RUN choco install nodejs --version="20.12.0"
10 |
11 | # verifies the right Node.js version is in the environment
12 | RUN node -v
13 |
14 | # verifies the right NPM version is in the environment
15 | RUN npm -v
--------------------------------------------------------------------------------
/docs/CNAME:
--------------------------------------------------------------------------------
1 | tunarr.com
2 |
--------------------------------------------------------------------------------
/docs/api-docs.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Scalar API Reference
5 |
6 |
9 |
10 |
11 |
12 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/docs/assets/add-media-source.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/add-media-source.png
--------------------------------------------------------------------------------
/docs/assets/channel-properties.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/channel-properties.png
--------------------------------------------------------------------------------
/docs/assets/channels-flex-filler.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/channels-flex-filler.png
--------------------------------------------------------------------------------
/docs/assets/channels-new.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/channels-new.png
--------------------------------------------------------------------------------
/docs/assets/docker-desktop.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/docker-desktop.webp
--------------------------------------------------------------------------------
/docs/assets/flex-filler-content.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/flex-filler-content.png
--------------------------------------------------------------------------------
/docs/assets/library-filler-additems.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/library-filler-additems.png
--------------------------------------------------------------------------------
/docs/assets/library-filler-menu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/library-filler-menu.png
--------------------------------------------------------------------------------
/docs/assets/library-filler-new-addmedia.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/library-filler-new-addmedia.png
--------------------------------------------------------------------------------
/docs/assets/library-filler-new-addmedia2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/library-filler-new-addmedia2.png
--------------------------------------------------------------------------------
/docs/assets/library-filler-new.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/library-filler-new.png
--------------------------------------------------------------------------------
/docs/assets/library-filler-save.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/library-filler-save.png
--------------------------------------------------------------------------------
/docs/assets/library-filler.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/library-filler.png
--------------------------------------------------------------------------------
/docs/assets/library.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/library.png
--------------------------------------------------------------------------------
/docs/assets/misc-commonissues-channelmappings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/misc-commonissues-channelmappings.png
--------------------------------------------------------------------------------
/docs/assets/new-plex-server-manual.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/new-plex-server-manual.png
--------------------------------------------------------------------------------
/docs/assets/plex-settings-channels.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/plex-settings-channels.png
--------------------------------------------------------------------------------
/docs/assets/plex-settings-dvr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/plex-settings-dvr.png
--------------------------------------------------------------------------------
/docs/assets/plex-settings-guide.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/plex-settings-guide.png
--------------------------------------------------------------------------------
/docs/assets/plex-settings-tuner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/plex-settings-tuner.png
--------------------------------------------------------------------------------
/docs/assets/plex-settings-tuner2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/plex-settings-tuner2.png
--------------------------------------------------------------------------------
/docs/assets/plex-settings-viewguide.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/plex-settings-viewguide.png
--------------------------------------------------------------------------------
/docs/assets/plex-settings-xmltv.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/plex-settings-xmltv.png
--------------------------------------------------------------------------------
/docs/assets/plex-settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/plex-settings.png
--------------------------------------------------------------------------------
/docs/assets/programming-additem.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/programming-additem.png
--------------------------------------------------------------------------------
/docs/assets/programming-addmedia-library.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/programming-addmedia-library.png
--------------------------------------------------------------------------------
/docs/assets/programming-addmedia.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/programming-addmedia.png
--------------------------------------------------------------------------------
/docs/assets/programming-addshow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/programming-addshow.png
--------------------------------------------------------------------------------
/docs/assets/programming-blockshuffle-noloop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/programming-blockshuffle-noloop.png
--------------------------------------------------------------------------------
/docs/assets/programming-blockshuffle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/programming-blockshuffle.png
--------------------------------------------------------------------------------
/docs/assets/programming-expandmenu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/programming-expandmenu.png
--------------------------------------------------------------------------------
/docs/assets/programming-mediaadded.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/programming-mediaadded.png
--------------------------------------------------------------------------------
/docs/assets/programming-shuffle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/programming-shuffle.png
--------------------------------------------------------------------------------
/docs/assets/scheduling-tools-random_slots.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/scheduling-tools-random_slots.png
--------------------------------------------------------------------------------
/docs/assets/scheduling-tools-random_slots_example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/scheduling-tools-random_slots_example.png
--------------------------------------------------------------------------------
/docs/assets/scheduling-tools-random_slots_example_weighted.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/scheduling-tools-random_slots_example_weighted.png
--------------------------------------------------------------------------------
/docs/assets/scheduling-tools-random_slots_preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/scheduling-tools-random_slots_preview.png
--------------------------------------------------------------------------------
/docs/assets/scheduling-tools-random_slots_preview_weighted.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/scheduling-tools-random_slots_preview_weighted.png
--------------------------------------------------------------------------------
/docs/assets/scheduling-tools-time_slots.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/scheduling-tools-time_slots.png
--------------------------------------------------------------------------------
/docs/assets/scheduling-tools-time_slots_example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/scheduling-tools-time_slots_example.png
--------------------------------------------------------------------------------
/docs/assets/scheduling-tools-time_slots_exampleflex.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/scheduling-tools-time_slots_exampleflex.png
--------------------------------------------------------------------------------
/docs/assets/scheduling-tools-time_slots_preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/scheduling-tools-time_slots_preview.png
--------------------------------------------------------------------------------
/docs/assets/scheduling-tools-time_slots_previewflex.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/scheduling-tools-time_slots_previewflex.png
--------------------------------------------------------------------------------
/docs/assets/serversettings-autoupdatechannels.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/serversettings-autoupdatechannels.png
--------------------------------------------------------------------------------
/docs/assets/settings-sources-edit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/settings-sources-edit.png
--------------------------------------------------------------------------------
/docs/assets/setup-finish.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/setup-finish.png
--------------------------------------------------------------------------------
/docs/assets/tunarr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/tunarr.png
--------------------------------------------------------------------------------
/docs/assets/watermark_form.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/watermark_form.png
--------------------------------------------------------------------------------
/docs/assets/welcome_page.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/welcome_page.png
--------------------------------------------------------------------------------
/docs/assets/welcome_page_ffmpeg_installed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/welcome_page_ffmpeg_installed.png
--------------------------------------------------------------------------------
/docs/assets/welcome_page_not_connected.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/assets/welcome_page_not_connected.png
--------------------------------------------------------------------------------
/docs/channel-config.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/channel-config.png
--------------------------------------------------------------------------------
/docs/channels.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/channels.png
--------------------------------------------------------------------------------
/docs/configure/channels/epg.md:
--------------------------------------------------------------------------------
1 | # EPG
2 |
3 | TBD
--------------------------------------------------------------------------------
/docs/configure/channels/index.md:
--------------------------------------------------------------------------------
1 | # Channels
2 |
3 | This page will display a brief overview of your channels.
4 |
5 | Click the "NEW" button to be brought to the [New Channel Properties](/configure/channels/properties) page.
6 |
7 | 
8 |
9 | Once you have created your first channel, head over to [Programming](/configure/programming) to start adding episodes.
--------------------------------------------------------------------------------
/docs/configure/channels/properties.md:
--------------------------------------------------------------------------------
1 | # Properties
2 |
3 | Choose a channel a name. Optionally, you can also add a thumbnail by uploading an image. This will be the logo visible within your Plex/Jellyfin channel guide. Transparent .png files are supported.
4 |
5 | On-Demand will allow your channels to behave similar to streaming services, where the watch states will only progress while you're actively viewing the channel. This is disabled by default, which means by default channels will behave similar to traditional televison where watch states will progress without you actively viewing the channel.
6 |
7 | 
8 |
9 | Click the ["FLEX" tab](/configure/channels/flex) if you'd like to configure optional filler content to play in-between episodes.
--------------------------------------------------------------------------------
/docs/configure/clients/index.md:
--------------------------------------------------------------------------------
1 | # Clients
2 |
3 | Once you have your channels created within Tunarr, you are ready to configure your clients. We currently have a guide written for [Plex](/configure/clients/plex), but [Jellyfin](/configure/clients/jellyfin) and Emby should work as well. You can also utilize generated M3U files with any 3rd party IPTV player app.
--------------------------------------------------------------------------------
/docs/configure/clients/jellyfin.md:
--------------------------------------------------------------------------------
1 | # Jellyfin
2 |
3 | TBD
--------------------------------------------------------------------------------
/docs/configure/library/custom-shows.md:
--------------------------------------------------------------------------------
1 | # Custom Shows
2 |
3 | Placeholder
4 |
--------------------------------------------------------------------------------
/docs/configure/library/index.md:
--------------------------------------------------------------------------------
1 | # Library
2 |
3 | [Filler](/configure/library/filler) lists are collections of videos that you may want to play during [Flex](/configure/channels/flex) time segments. Flex is time within a channel that does not have a program scheduled (usually used for padding).
4 |
5 | [Custom Shows](/configure/library/custom-shows) are sequences of videos that represent a episodes of a virtual TV show. When you add these shows to a channel, the schedule tools will treat the videos as if they belonged to a single TV show.
--------------------------------------------------------------------------------
/docs/configure/scheduling-tools/balance.md:
--------------------------------------------------------------------------------
1 | # Balance
2 |
3 | TBD
--------------------------------------------------------------------------------
/docs/configure/scheduling-tools/consolidate.md:
--------------------------------------------------------------------------------
1 | # Consolidate
2 |
3 | TBD
--------------------------------------------------------------------------------
/docs/configure/scheduling-tools/replicate.md:
--------------------------------------------------------------------------------
1 | # Replicate
2 |
3 | TBD
--------------------------------------------------------------------------------
/docs/configure/system/index.md:
--------------------------------------------------------------------------------
1 | # System
2 |
--------------------------------------------------------------------------------
/docs/configure/transcoding.md:
--------------------------------------------------------------------------------
1 | # Transcoding
2 |
--------------------------------------------------------------------------------
/docs/dev/contributing.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
--------------------------------------------------------------------------------
/docs/generated/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/generated/.gitkeep
--------------------------------------------------------------------------------
/docs/misc/troubleshooting.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/misc/troubleshooting.md
--------------------------------------------------------------------------------
/docs/plex-guide.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/plex-guide.png
--------------------------------------------------------------------------------
/docs/plex-stream.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/docs/plex-stream.png
--------------------------------------------------------------------------------
/docs/stylesheets/extra.css:
--------------------------------------------------------------------------------
1 | [data-md-color-scheme='tunarr'] {
2 | --md-primary-fg-color: #008c93;
3 | --md-primary-fg-color--light: #ecb7b7;
4 | --md-primary-fg-color--dark: #90030c;
5 | }
6 |
7 | [data-md-color-scheme='tunarr-dark'] {
8 | color-scheme: dark;
9 | --md-hue: 182;
10 | --md-primary-fg-color: #008c93;
11 | /* --md-primary-fg-color--light: #ecb7b7; */
12 | /* --md-primary-fg-color--dark: #90030c; */
13 | }
14 |
15 | :root {
16 | --md-primary-fg-color: #008c93;
17 | --md-accent-fg-color: #004b79;
18 | }
19 |
--------------------------------------------------------------------------------
/patches/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/patches/.gitkeep
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - server
3 | - web
4 | - types
5 | - shared
--------------------------------------------------------------------------------
/scripts/init-husky.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | # Check if bunx is installed
4 | if command -v bun &> /dev/null; then
5 | # Run the command with bunx
6 | bun -e "if (process.env.NODE_ENV !== 'production'){process.exit(1)}" || bunx husky install
7 | else
8 | # Fall back to npx if bunx is not available
9 | node -e "if (process.env.NODE_ENV !== 'production'){process.exit(1)}" || husky install
10 | fi
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | # Keep environment variables out of version control
3 | .env
4 | out/
5 | temp/
6 | streams/
7 | *.log
8 | **/web/
9 | *.zip
10 | bin/
11 |
12 | src/generated/**
13 | !src/generated/web-imports.d.ts
14 | !src/generated/.gitkeep
15 | emby.ts
16 |
--------------------------------------------------------------------------------
/server/bunfig.toml:
--------------------------------------------------------------------------------
1 | [test]
2 | preload = ['./src/testing/matchers/PixelFormatMatcher.ts']
3 |
--------------------------------------------------------------------------------
/server/cjs-shim.ts:
--------------------------------------------------------------------------------
1 | import { createRequire } from 'node:module';
2 | import path from 'node:path';
3 | import url from 'node:url';
4 |
5 | globalThis.require = createRequire(import.meta.url);
6 | globalThis.__filename = url.fileURLToPath(import.meta.url);
7 | globalThis.__dirname = path.dirname(__filename);
8 |
--------------------------------------------------------------------------------
/server/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'drizzle-kit';
2 |
3 | export default defineConfig({
4 | dialect: 'sqlite',
5 | schema: './src/db/schema/**/*.ts',
6 | casing: 'snake_case',
7 | dbCredentials: {
8 | url: process.env.TUNARR_DATABASE_PATH,
9 | },
10 | });
11 |
--------------------------------------------------------------------------------
/server/drizzle/0003_ancient_synch.sql:
--------------------------------------------------------------------------------
1 | DROP INDEX `unique_program_single_external_id`;--> statement-breakpoint
2 | CREATE UNIQUE INDEX `unique_program_multiple_external_id_media_source` ON `program_external_id` (`program_uuid`,`source_type`,`media_source_id`) WHERE `media_source_id` is not null;--> statement-breakpoint
3 | CREATE UNIQUE INDEX `unique_program_single_external_id_media_source` ON `program_external_id` (`program_uuid`,`source_type`) WHERE `media_source_id` is null;--> statement-breakpoint
4 | CREATE UNIQUE INDEX `unique_program_single_external_id` ON `program_external_id` (`program_uuid`,`source_type`) WHERE `external_source_id` is null;
--------------------------------------------------------------------------------
/server/drizzle/0005_fair_whistler.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE `channel` ADD `subtitles_enabled` integer DEFAULT false;--> statement-breakpoint
2 | ALTER TABLE `channel` DROP COLUMN `subtitle_filter`;--> statement-breakpoint
3 | ALTER TABLE `channel_subtitle_preferences` ADD `filter_type` text DEFAULT 'any';--> statement-breakpoint
4 | ALTER TABLE `custom_show_subtitle_preferences` ADD `filter_type` NOT NULL text DEFAULT 'any';
--------------------------------------------------------------------------------
/server/esbuild/bundlerPathsOverrideShim.ts:
--------------------------------------------------------------------------------
1 | globalThis.__bundlerPathsOverrides = {
2 | ...(globalThis.__bundlerPathsOverrides || {}),
3 | './basicPrettyTransport.ts': './basicPrettyTransport.js',
4 | };
5 |
--------------------------------------------------------------------------------
/server/esbuild/importMetaUrlShim.ts:
--------------------------------------------------------------------------------
1 | export const __import_meta_url =
2 | typeof document === 'undefined'
3 | ? new (require('url'.replace('', '')).URL)('file:' + __filename).href
4 | : (document.currentScript && document.currentScript.src) ||
5 | new URL('main.js', document.baseURI).href;
6 |
--------------------------------------------------------------------------------
/server/esbuild/node-protocol.ts:
--------------------------------------------------------------------------------
1 | import { Plugin } from 'esbuild';
2 |
3 | /**
4 | * The node: protocol was added to require in Node v14.18.0
5 | * https://nodejs.org/api/esm.html#node-imports
6 | */
7 | export const nodeProtocolPlugin = (): Plugin => {
8 | const nodeProtocol = 'node:';
9 |
10 | return {
11 | name: 'node-protocol-plugin',
12 | setup({ onResolve }) {
13 | onResolve(
14 | {
15 | filter: /^node:/,
16 | },
17 | ({ path }) => ({
18 | path: path.slice(nodeProtocol.length),
19 | external: true,
20 | }),
21 | );
22 | },
23 | };
24 | };
25 |
--------------------------------------------------------------------------------
/server/pkg.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "pkg": {
3 | "assets": ["./dist/**/*"],
4 | "outputPath": "dist/bin"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/server/src/cli/commands.ts:
--------------------------------------------------------------------------------
1 | import { GenerateOpenApiCommand } from './GenerateOpenApiCommand.ts';
2 | import { RunServerCommand } from './RunServerCommand.ts';
3 | import { databaseCommands } from './database/databaseCommands.ts';
4 | import { LegacyMigrateCommand } from './legacyMigrateCommand.ts';
5 | import { RunFixerCommand } from './runFixerCommand.ts';
6 | import { settingsCommands } from './settings/settingsCommands.ts';
7 |
8 | export const commands = [
9 | settingsCommands,
10 | LegacyMigrateCommand,
11 | RunFixerCommand,
12 | RunServerCommand,
13 | databaseCommands,
14 | GenerateOpenApiCommand,
15 | ];
16 |
--------------------------------------------------------------------------------
/server/src/cli/database/DatabaseListMigrationsCommand.ts:
--------------------------------------------------------------------------------
1 | import { DBAccess } from '@/db/DBAccess.js';
2 | import { isEmpty } from 'lodash-es';
3 | import type { CommandModule } from 'yargs';
4 |
5 | export const DatabaseListMigrationsCommand: CommandModule = {
6 | command: 'list',
7 | describe: 'Tunarr database migration commands',
8 |
9 | handler: async () => {
10 | const migrator = new DBAccess().getOrCreateConnection().getMigrator();
11 | const migrations = await migrator.getMigrations();
12 | if (isEmpty(migrations)) {
13 | console.info('No migrations found!');
14 | return;
15 | }
16 |
17 | console.info(
18 | `Found ${migrations.length} migration${migrations.length > 1 ? 's' : ''}`,
19 | );
20 |
21 | for (const migration of migrations) {
22 | console.log(`${migration.executedAt ? '✓' : ' '} ${migration.name}`);
23 | }
24 | },
25 | };
26 |
--------------------------------------------------------------------------------
/server/src/cli/database/DatabaseMigrateToLatestCommand.ts:
--------------------------------------------------------------------------------
1 | import type { CommandModule } from 'yargs';
2 | import { container } from '../../container.ts';
3 | import { DBAccess } from '../../db/DBAccess.ts';
4 | import { getDefaultDatabaseName } from '../../util/defaults.ts';
5 |
6 | interface DatabaseMigrateUpCommandArgs {
7 | migrationName?: string;
8 | }
9 |
10 | export const DatabaseMigrateToLatestCommand: CommandModule<
11 | DatabaseMigrateUpCommandArgs,
12 | DatabaseMigrateUpCommandArgs
13 | > = {
14 | command: 'sync',
15 | describe: 'Apply the next run or up to the specificed migration',
16 | builder: (yargs) =>
17 | yargs.positional('migrationName', { demandOption: false, type: 'string' }),
18 | handler: async () => {
19 | await container
20 | .get(DBAccess)
21 | .migrateExistingDatabase(getDefaultDatabaseName());
22 | },
23 | };
24 |
--------------------------------------------------------------------------------
/server/src/cli/database/databaseCommandUtil.ts:
--------------------------------------------------------------------------------
1 | import type { Maybe } from '@/types/util.js';
2 | import { isNonEmptyString } from '@/util/index.js';
3 | import type { Migrator } from 'kysely';
4 | import { isNil, isUndefined } from 'lodash-es';
5 |
6 | export async function isWrongMigrationDirection(
7 | name: Maybe,
8 | expectedMigrationDiration: 'up' | 'down',
9 | migrator: Migrator,
10 | ) {
11 | if (!isNonEmptyString(name)) {
12 | return false;
13 | }
14 |
15 | const migrations = await migrator.getMigrations();
16 |
17 | return !isUndefined(
18 | migrations.find(
19 | (migration) =>
20 | migration.name === name &&
21 | ((expectedMigrationDiration === 'up' && !isNil(migration.executedAt)) ||
22 | (expectedMigrationDiration === 'down' &&
23 | isNil(migration.executedAt))),
24 | ),
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/server/src/cli/settings/settingsCommands.ts:
--------------------------------------------------------------------------------
1 | import type { CommandModule } from 'yargs';
2 | import { SettingsUpdateCommand } from './settingsUpdateCommand.ts';
3 | import { SettingsViewCommand } from './settingsViewCommand.ts';
4 |
5 | export const settingsCommands: CommandModule = {
6 | command: 'settings ',
7 | describe: 'View/Update settings',
8 | builder: (yargs) =>
9 | yargs.command(SettingsViewCommand).command(SettingsUpdateCommand),
10 | handler: () => {},
11 | };
12 |
--------------------------------------------------------------------------------
/server/src/cli/types.ts:
--------------------------------------------------------------------------------
1 | import type { LogLevels } from '@/util/logging/LoggerFactory.js';
2 |
3 | export type GlobalArgsType = {
4 | log_level: LogLevels;
5 | verbose: number;
6 | database: string;
7 | // Should this be here?
8 | force_migration: boolean;
9 | };
10 |
--------------------------------------------------------------------------------
/server/src/db/SettingsDB.test.ts:
--------------------------------------------------------------------------------
1 | import { Low, Memory } from 'lowdb';
2 |
3 | type Data = {
4 | obj: {
5 | x: number;
6 | y: string;
7 | };
8 | };
9 |
10 | test('LowDB referential uppdates', async () => {
11 | const db = new Low(new Memory(), { obj: { x: 1, y: 'string' } });
12 | await db.read();
13 | const data = db.data;
14 | console.log(data);
15 | data.obj.x = 100;
16 | await db.write();
17 | console.log(db.data);
18 | });
19 |
--------------------------------------------------------------------------------
/server/src/db/backup/DatabaseBackup.ts:
--------------------------------------------------------------------------------
1 | import type { FileBackupOutput } from '@tunarr/types/schemas';
2 | import type { ISettingsDB } from '../interfaces/ISettingsDB.ts';
3 |
4 | export type SuccessfulBackupResult = {
5 | type: 'success';
6 | data: T;
7 | };
8 |
9 | export type FailureBackupResult = {
10 | type: 'error';
11 | };
12 |
13 | export type BackupResult =
14 | | SuccessfulBackupResult
15 | | FailureBackupResult;
16 |
17 | export abstract class DatabaseBackup {
18 | constructor(protected settings: ISettingsDB) {}
19 |
20 | abstract backup(config: FileBackupOutput): Promise>;
21 | }
22 |
--------------------------------------------------------------------------------
/server/src/db/backup/DatabaseBackupStrategy.ts:
--------------------------------------------------------------------------------
1 | export abstract class DatabaseBackupStrategy {}
2 |
--------------------------------------------------------------------------------
/server/src/db/backup/SqliteDatabaseBackup.ts:
--------------------------------------------------------------------------------
1 | import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
2 | // import { Database } from '@db/sqlite';
3 | import BetterSqlite3 from 'better-sqlite3';
4 |
5 | export class SqliteDatabaseBackup {
6 | #logger = LoggerFactory.child({ className: SqliteDatabaseBackup.name });
7 |
8 | async backup(dbName: string, outFile: string) {
9 | const conn = BetterSqlite3(dbName, {
10 | fileMustExist: true,
11 | });
12 | try {
13 | await conn.backup(outFile, {
14 | progress: (info) => {
15 | this.#logger.trace(
16 | 'Backed up %d pages of DB, with %d reminaing',
17 | info.totalPages - info.remainingPages,
18 | info.remainingPages,
19 | );
20 | return 100;
21 | },
22 | });
23 | } finally {
24 | conn.close();
25 | }
26 |
27 | return outFile;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/src/db/converters/transcodeConfigConverters.ts:
--------------------------------------------------------------------------------
1 | import type { TranscodeConfig } from '@tunarr/types';
2 | import { numberToBoolean } from '../../util/sqliteUtil.ts';
3 | import type { TranscodeConfig as TrannscodeConfigDao } from '../schema/TranscodeConfig.ts';
4 |
5 | export function dbTranscodeConfigToApiSchema(
6 | config: TrannscodeConfigDao,
7 | ): TranscodeConfig {
8 | return {
9 | ...config,
10 | id: config.uuid,
11 | disableChannelOverlay: numberToBoolean(config.disableChannelOverlay),
12 | normalizeFrameRate: numberToBoolean(config.normalizeFrameRate),
13 | deinterlaceVideo: numberToBoolean(config.deinterlaceVideo),
14 | isDefault: numberToBoolean(config.isDefault),
15 | } satisfies TranscodeConfig;
16 | }
17 |
--------------------------------------------------------------------------------
/server/src/db/custom_types/ProgramSourceType.ts:
--------------------------------------------------------------------------------
1 | import { enumKeys } from '@/util/enumUtil.js';
2 |
3 | export enum ProgramSourceType {
4 | PLEX = 'plex',
5 | JELLYFIN = 'jellyfin',
6 | EMBY = 'emby',
7 | }
8 |
9 | export function programSourceTypeFromString(
10 | str: string,
11 | ): ProgramSourceType | undefined {
12 | for (const key of enumKeys(ProgramSourceType)) {
13 | const value = ProgramSourceType[key];
14 | if (key.toLowerCase() === str) {
15 | return value;
16 | }
17 | }
18 | return;
19 | }
20 |
--------------------------------------------------------------------------------
/server/src/db/schema/CachedImage.ts:
--------------------------------------------------------------------------------
1 | import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
2 | import type { Insertable, Selectable } from 'kysely';
3 | import { type KyselifyBetter } from './KyselifyBetter.ts';
4 |
5 | export const CachedImage = sqliteTable('cached_image', {
6 | hash: text().notNull().primaryKey(),
7 | mimeType: text(),
8 | url: text().notNull(),
9 | });
10 |
11 | export type CachedImageTable = KyselifyBetter;
12 | export type CachedImage = Selectable;
13 | export type NewCachedImage = Insertable;
14 |
--------------------------------------------------------------------------------
/server/src/db/schema/MikroOrmMigrations.d.ts:
--------------------------------------------------------------------------------
1 | import type { Generated, Selectable } from 'kysely';
2 |
3 | export interface MikroOrmMigrationsTable {
4 | executedAt: Generated; // Timestamp
5 | id: Generated;
6 | name: string;
7 | }
8 |
9 | export type MikroOrmMigrations = Selectable;
10 |
--------------------------------------------------------------------------------
/server/src/external/Redacter.ts:
--------------------------------------------------------------------------------
1 | import type { InternalAxiosRequestConfig } from 'axios';
2 |
3 | interface Redacter {
4 | // Potentially mutates innput
5 | redact(input: Input): Input;
6 | }
7 |
8 | export abstract class AxiosRequestRedacter
9 | implements Redacter
10 | {
11 | redact(
12 | conf: InternalAxiosRequestConfig,
13 | ): InternalAxiosRequestConfig {
14 | if (conf.headers) {
15 | if (conf.headers.Authorization) {
16 | conf.headers.Authorization = '';
17 | }
18 | }
19 |
20 | return conf;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/server/src/external/emby/EmbyRequestRedacter.ts:
--------------------------------------------------------------------------------
1 | import { AxiosRequestRedacter } from '@/external/Redacter.js';
2 | import type { InternalAxiosRequestConfig } from 'axios';
3 | import { isObject } from 'lodash-es';
4 |
5 | export class EmbyRequestRedacter extends AxiosRequestRedacter {
6 | redact(
7 | conf: InternalAxiosRequestConfig,
8 | ): InternalAxiosRequestConfig {
9 | conf = super.redact(conf);
10 | if (conf.url?.includes('/AuthenticateByName')) {
11 | conf.data = '';
12 | }
13 |
14 | if (conf.headers) {
15 | if (conf.headers['X-Emby-Token']) {
16 | conf.headers['X-Emby-Token'] = '';
17 | }
18 | }
19 |
20 | if (conf.params && isObject(conf.params)) {
21 | if (conf.params['X-Emby-Token']) {
22 | conf.headers['X-Emby-Token'] = '';
23 | }
24 | }
25 |
26 | return conf;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/server/src/external/jellyfin/JellyfinRequestRedacter.ts:
--------------------------------------------------------------------------------
1 | import { AxiosRequestRedacter } from '@/external/Redacter.js';
2 | import type { InternalAxiosRequestConfig } from 'axios';
3 | import { isObject } from 'lodash-es';
4 |
5 | export class JellyfinRequestRedacter extends AxiosRequestRedacter {
6 | redact(
7 | conf: InternalAxiosRequestConfig,
8 | ): InternalAxiosRequestConfig {
9 | conf = super.redact(conf);
10 | if (conf.url?.includes('/AuthenticateByName')) {
11 | conf.data = '';
12 | }
13 |
14 | if (conf.headers) {
15 | if (conf.headers['X-Emby-Token']) {
16 | conf.headers['X-Emby-Token'] = '';
17 | }
18 | }
19 |
20 | if (conf.params && isObject(conf.params)) {
21 | if (conf.params['X-Emby-Token']) {
22 | conf.headers['X-Emby-Token'] = '';
23 | }
24 | }
25 |
26 | return conf;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/server/src/external/plex/PlexRequestRedacter.ts:
--------------------------------------------------------------------------------
1 | import { AxiosRequestRedacter } from '@/external/Redacter.js';
2 | import type { InternalAxiosRequestConfig } from 'axios';
3 | import { isObject } from 'lodash-es';
4 |
5 | export class PlexRequestRedacter extends AxiosRequestRedacter {
6 | redact(
7 | conf: InternalAxiosRequestConfig,
8 | ): InternalAxiosRequestConfig {
9 | conf = super.redact(conf);
10 | if (conf.headers) {
11 | if (conf.headers.Authorization) {
12 | conf.headers.Authorization = '';
13 | }
14 |
15 | if (conf.headers['X-Plex-Token']) {
16 | conf.headers['X-Plex-Token'] = '';
17 | }
18 | }
19 |
20 | if (conf.params && isObject(conf.params)) {
21 | if (conf.params['X-Plex-Token']) {
22 | conf.params['X-Plex-Token'] = '';
23 | }
24 | }
25 |
26 | return conf;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/GetLastPtsDuration.test.ts:
--------------------------------------------------------------------------------
1 | import { SettingsDB } from '@/db/SettingsDB.js';
2 | import {
3 | getFakeSettingsDb,
4 | setTestGlobalOptions,
5 | } from '../testing/getFakeSettingsDb.js';
6 | import { GetLastPtsDurationTask } from './GetLastPtsDuration.js';
7 |
8 | let settingsDB: SettingsDB;
9 | beforeAll(async () => {
10 | await setTestGlobalOptions();
11 | settingsDB = getFakeSettingsDb();
12 | });
13 |
14 | test.skip('Get last duration', async () => {
15 | const task = new GetLastPtsDurationTask(settingsDB);
16 | const result = await task.run(
17 | '/home/christian/Desktop/ffmpeg-test/test-out.ts',
18 | );
19 | console.log(result);
20 | });
21 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/MediaStream.test.ts:
--------------------------------------------------------------------------------
1 | import { VideoStream } from '@/ffmpeg/builder/MediaStream.js';
2 | import { PixelFormatYuv420P } from '@/ffmpeg/builder/format/PixelFormat.js';
3 | import { FrameSize } from '@/ffmpeg/builder/types.js';
4 |
5 | describe('MediaStream', () => {
6 | test('squarePixelFrameSize @ FHD', () => {
7 | const stream = VideoStream.create({
8 | codec: 'h264',
9 | frameSize: FrameSize.withDimensions(720, 480),
10 | index: 0,
11 | sampleAspectRatio: null,
12 | displayAspectRatio: '1.33',
13 | pixelFormat: new PixelFormatYuv420P(),
14 | });
15 |
16 | console.log(stream.squarePixelFrameSize(FrameSize.FHD));
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/capabilities/NoHardwareCapabilities.ts:
--------------------------------------------------------------------------------
1 | import { BaseFfmpegHardwareCapabilities } from './BaseFfmpegHardwareCapabilities.ts';
2 |
3 | export class NoHardwareCapabilities extends BaseFfmpegHardwareCapabilities {
4 | readonly type = 'none' as const;
5 | constructor() {
6 | super();
7 | }
8 |
9 | canDecode(): boolean {
10 | return false;
11 | }
12 |
13 | canEncode(): boolean {
14 | return false;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/capabilities/VainfoProcessHelper.ts:
--------------------------------------------------------------------------------
1 | import type { Nilable } from '../../../types/util.ts';
2 | import { ChildProcessHelper } from '../../../util/ChildProcessHelper.ts';
3 | import { attempt, isNonEmptyString } from '../../../util/index.ts';
4 |
5 | export class VainfoProcessHelper {
6 | async getVainfoOutput(
7 | display: string,
8 | vaapiDevice: string,
9 | vaapiDriver: Nilable,
10 | swallowError: boolean = false,
11 | ) {
12 | return attempt(() =>
13 | new ChildProcessHelper().getStdout(
14 | 'vainfo',
15 | ['--display', display, '--device', vaapiDevice, '-a'],
16 | swallowError,
17 | isNonEmptyString(vaapiDriver)
18 | ? { LIBVA_DRIVER_NAME: vaapiDriver }
19 | : undefined,
20 | swallowError,
21 | ),
22 | );
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/Av1Decoder.ts:
--------------------------------------------------------------------------------
1 | import { SoftwareDecoder } from '@/ffmpeg/builder/decoder/SoftwareDecoder.js';
2 |
3 | export class Av1Decoder extends SoftwareDecoder {
4 | constructor(public name: 'libdav1d' | 'libaom-av1' | 'av1') {
5 | super();
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/Decoder.ts:
--------------------------------------------------------------------------------
1 | import { InputOption } from '@/ffmpeg/builder/options/input/InputOption.js';
2 |
3 | export abstract class Decoder extends InputOption {
4 | abstract readonly name: string;
5 | }
6 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/H264Decoder.ts:
--------------------------------------------------------------------------------
1 | import { SoftwareDecoder } from './SoftwareDecoder.ts';
2 |
3 | export class H264Decoder extends SoftwareDecoder {
4 | readonly name = 'h264';
5 | }
6 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/HevcDecoder.ts:
--------------------------------------------------------------------------------
1 | import { SoftwareDecoder } from './SoftwareDecoder.ts';
2 |
3 | export class HevcDecoder extends SoftwareDecoder {
4 | readonly name: string = 'hevc';
5 | }
6 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/ImplicitDecoder.ts:
--------------------------------------------------------------------------------
1 | import type { InputSource } from '@/ffmpeg/builder/input/InputSource.js';
2 | import { SoftwareDecoder } from './SoftwareDecoder.ts';
3 |
4 | export class ImplicitDecoder extends SoftwareDecoder {
5 | readonly name = '';
6 | options(_inputSource: InputSource): string[] {
7 | return [];
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/Mpeg2Decoder.ts:
--------------------------------------------------------------------------------
1 | import { SoftwareDecoder } from './SoftwareDecoder.ts';
2 |
3 | export class Mpeg2Decoder extends SoftwareDecoder {
4 | readonly name: string = 'mpeg2video';
5 | }
6 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/Mpeg4Decoder.ts:
--------------------------------------------------------------------------------
1 | import { SoftwareDecoder } from './SoftwareDecoder.ts';
2 |
3 | export class Mpeg4Decoder extends SoftwareDecoder {
4 | readonly name: string = 'mpeg4';
5 | }
6 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/RawVideoDecoder.ts:
--------------------------------------------------------------------------------
1 | import { SoftwareDecoder } from './SoftwareDecoder.ts';
2 |
3 | export class RawVideoDecoder extends SoftwareDecoder {
4 | readonly name: string = 'rawvideo';
5 | }
6 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/SoftwareDecoder.ts:
--------------------------------------------------------------------------------
1 | import { FrameDataLocation } from '@/ffmpeg/builder/types.js';
2 | import { BaseDecoder } from './BaseDecoder.ts';
3 |
4 | export abstract class SoftwareDecoder extends BaseDecoder {
5 | protected _outputFrameDataLocation: FrameDataLocation =
6 | FrameDataLocation.Software;
7 | }
8 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/Vc1Decoder.ts:
--------------------------------------------------------------------------------
1 | import { SoftwareDecoder } from './SoftwareDecoder.ts';
2 |
3 | export class Vc1Decoder extends SoftwareDecoder {
4 | readonly name: string = 'vc1';
5 | }
6 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/nvidia/NvidiaAv1Decoder.ts:
--------------------------------------------------------------------------------
1 | import { NvidiaDecoder } from './NvidiaDecoder.ts';
2 |
3 | export class NvidiaAv1Decoder extends NvidiaDecoder {
4 | readonly name = 'av1_cuvid';
5 | }
6 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/nvidia/NvidiaH264Decoder.ts:
--------------------------------------------------------------------------------
1 | import { NvidiaDecoder } from './NvidiaDecoder.ts';
2 |
3 | export class NvidiaH264Decoder extends NvidiaDecoder {
4 | readonly name = 'h264_cuvid';
5 | }
6 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/nvidia/NvidiaHevcDecoder.ts:
--------------------------------------------------------------------------------
1 | import { NvidiaDecoder } from './NvidiaDecoder.ts';
2 |
3 | export class NvidiaHevcDecoder extends NvidiaDecoder {
4 | readonly name = 'hevc_cuvid';
5 | }
6 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/nvidia/NvidiaImplicitDecoder.ts:
--------------------------------------------------------------------------------
1 | import type { InputSource } from '@/ffmpeg/builder/input/InputSource.js';
2 | import { NvidiaDecoder } from './NvidiaDecoder.ts';
3 |
4 | export class NvidiaImplicitDecoder extends NvidiaDecoder {
5 | constructor() {
6 | super('cuda');
7 | }
8 |
9 | readonly name = '';
10 |
11 | options(_inputFile: InputSource): string[] {
12 | return ['-hwaccel_output_format', 'cuda'];
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/nvidia/NvidiaMpeg2Decoder.ts:
--------------------------------------------------------------------------------
1 | import type { HardwareAccelerationMode } from '@/db/schema/TranscodeConfig.js';
2 | import { FrameDataLocation } from '../../types.ts';
3 | import { NvidiaDecoder } from './NvidiaDecoder.ts';
4 |
5 | export class NvidiaMpeg2Decoder extends NvidiaDecoder {
6 | readonly name = 'mpeg2_cuvid';
7 |
8 | constructor(
9 | hardwareAccelerationMode: HardwareAccelerationMode,
10 | private contentIsInterlaced: boolean,
11 | ) {
12 | super(hardwareAccelerationMode);
13 | }
14 |
15 | override get _outputFrameDataLocation() {
16 | return this.contentIsInterlaced
17 | ? FrameDataLocation.Software
18 | : super._outputFrameDataLocation;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/nvidia/NvidiaVc1Decoder.ts:
--------------------------------------------------------------------------------
1 | import { NvidiaDecoder } from './NvidiaDecoder.ts';
2 |
3 | export class NvidiaVc1Decoder extends NvidiaDecoder {
4 | readonly name = 'vc1_cuvid';
5 | }
6 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/nvidia/NvidiaVp9Decoder.ts:
--------------------------------------------------------------------------------
1 | import { NvidiaDecoder } from './NvidiaDecoder.ts';
2 |
3 | export class NvidiaVp9Decoder extends NvidiaDecoder {
4 | readonly name = 'vp9_cuvid';
5 | }
6 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/qsv/H264QsvDecoder.ts:
--------------------------------------------------------------------------------
1 | import { PixelFormatNv12 } from '@/ffmpeg/builder/format/PixelFormat.js';
2 | import type { FrameState } from '@/ffmpeg/builder/state/FrameState.js';
3 | import { type BaseFfmpegHardwareCapabilities } from '../../capabilities/BaseFfmpegHardwareCapabilities.ts';
4 | import { QsvDecoder } from './QsvDecoder.ts';
5 |
6 | export class H264QsvDecoder extends QsvDecoder {
7 | constructor(hardwareCapabilities: BaseFfmpegHardwareCapabilities) {
8 | super('h264_qsv', hardwareCapabilities);
9 | }
10 |
11 | nextState(currentState: FrameState): FrameState {
12 | const next = super.nextState(currentState);
13 |
14 | if (currentState.pixelFormat) {
15 | return next.update({
16 | pixelFormat: new PixelFormatNv12(currentState.pixelFormat),
17 | });
18 | }
19 |
20 | return next;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/qsv/Mpeg2QsvDecoder.ts:
--------------------------------------------------------------------------------
1 | import { BaseFfmpegHardwareCapabilities } from '../../capabilities/BaseFfmpegHardwareCapabilities.ts';
2 | import { QsvDecoder } from './QsvDecoder.ts';
3 |
4 | export class Mpeg2QsvDecoder extends QsvDecoder {
5 | constructor(hardwareCapabilities: BaseFfmpegHardwareCapabilities) {
6 | super('mpeg2_qsv', hardwareCapabilities);
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/qsv/Vc1QsvDecoder.ts:
--------------------------------------------------------------------------------
1 | import { BaseFfmpegHardwareCapabilities } from '../../capabilities/BaseFfmpegHardwareCapabilities.ts';
2 | import { QsvDecoder } from './QsvDecoder.ts';
3 |
4 | export class Vc1QsvDecoder extends QsvDecoder {
5 | constructor(hardwareCapabilities: BaseFfmpegHardwareCapabilities) {
6 | super('vc1_qsv', hardwareCapabilities);
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/decoder/videotoolbox/VideoToolboxDecoder.ts:
--------------------------------------------------------------------------------
1 | import { BaseDecoder } from '@/ffmpeg/builder/decoder/BaseDecoder.js';
2 | import type { MediaStream } from '../../MediaStream.ts';
3 | import type { InputSource } from '../../input/InputSource.ts';
4 |
5 | export class VideoToolboxDecoder extends BaseDecoder {
6 | readonly name: string = '';
7 |
8 | options(_inputSource: InputSource): string[] {
9 | return [];
10 | }
11 |
12 | protected _outputFrameDataLocation: 'unknown' | 'hardware' | 'software' =
13 | 'software';
14 | }
15 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/encoder/CopyEncoders.ts:
--------------------------------------------------------------------------------
1 | import type { VideoFormat } from '../constants.ts';
2 | import { AudioEncoder, BaseEncoder, VideoEncoder } from './BaseEncoder.ts';
3 |
4 | export class CopyVideoEncoder extends VideoEncoder {
5 | protected videoFormat: VideoFormat;
6 |
7 | constructor() {
8 | super('copy');
9 | }
10 | }
11 |
12 | export class CopyAudioEncoder extends AudioEncoder {
13 | constructor() {
14 | super('copy');
15 | }
16 | }
17 |
18 | export class CopyAllEncoder extends BaseEncoder {
19 | constructor() {
20 | super('copy', 'all');
21 | }
22 |
23 | options(): string[] {
24 | return ['-c', 'copy'];
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/encoder/CopyVideoEncoder.ts:
--------------------------------------------------------------------------------
1 | import { VideoEncoder } from './BaseEncoder.js';
2 |
3 | export class CopyVideoEncoder extends VideoEncoder {
4 | protected readonly videoFormat = '';
5 |
6 | constructor() {
7 | super('copy');
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/encoder/Encoder.ts:
--------------------------------------------------------------------------------
1 | import { OutputOption } from '@/ffmpeg/builder/options/OutputOption.js';
2 | import type { StreamKind } from '@/ffmpeg/builder/types.js';
3 |
4 | export abstract class Encoder extends OutputOption {
5 | name: string;
6 | kind: StreamKind;
7 | filter: string;
8 | }
9 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/encoder/ImplicitVideoEncoder.ts:
--------------------------------------------------------------------------------
1 | import { VideoEncoder } from './BaseEncoder.js';
2 |
3 | export class ImplicitVideoEncoder extends VideoEncoder {
4 | protected readonly videoFormat = '';
5 |
6 | constructor() {
7 | super('');
8 | }
9 |
10 | options(): string[] {
11 | return [];
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/encoder/LibOpenH264Encoder.ts:
--------------------------------------------------------------------------------
1 | import { VideoFormats } from '@/ffmpeg/builder/constants.js';
2 | import type { Nullable } from '@/types/util.js';
3 | import { isNonEmptyString } from '@/util/index.js';
4 | import { VideoEncoder } from './BaseEncoder.js';
5 |
6 | export class LibOpenH264Encoder extends VideoEncoder {
7 | protected readonly videoFormat = VideoFormats.H264;
8 |
9 | constructor(private videoProfile: Nullable) {
10 | super('libopenh264');
11 | }
12 |
13 | options(): string[] {
14 | const opts = [...super.options()];
15 | if (isNonEmptyString(this.videoProfile)) {
16 | opts.push('-profile:v', this.videoProfile);
17 | }
18 | return opts;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/encoder/Libx264Encoder.ts:
--------------------------------------------------------------------------------
1 | import { VideoFormats } from '@/ffmpeg/builder/constants.js';
2 | import type { Nullable } from '@/types/util.js';
3 | import { isNonEmptyString } from '@/util/index.js';
4 | import { VideoEncoder } from './BaseEncoder.js';
5 |
6 | export class Libx264Encoder extends VideoEncoder {
7 | protected readonly videoFormat = VideoFormats.H264;
8 |
9 | constructor(
10 | private videoProfile: Nullable,
11 | private videoPreset: Nullable,
12 | ) {
13 | super('libx264');
14 | }
15 |
16 | options(): string[] {
17 | const opts = [...super.options()];
18 | if (isNonEmptyString(this.videoPreset)) {
19 | opts.push('-profile:v', this.videoPreset);
20 | }
21 | if (isNonEmptyString(this.videoProfile)) {
22 | opts.push('-profile:v', this.videoProfile);
23 | }
24 | return opts;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/encoder/Mpeg2VideoEncoder.ts:
--------------------------------------------------------------------------------
1 | import { VideoFormats } from '@/ffmpeg/builder/constants.js';
2 | import { VideoEncoder } from './BaseEncoder.js';
3 |
4 | export class Mpeg2VideoEncoder extends VideoEncoder {
5 | protected videoFormat = VideoFormats.Mpeg2Video;
6 |
7 | constructor() {
8 | super('mpeg2video');
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/encoder/RawVideoEncoder.ts:
--------------------------------------------------------------------------------
1 | import { VideoFormats } from '@/ffmpeg/builder/constants.js';
2 | import { VideoEncoder } from './BaseEncoder.js';
3 |
4 | export class RawVideoEncoder extends VideoEncoder {
5 | protected videoFormat = VideoFormats.Raw;
6 |
7 | constructor() {
8 | super('rawvideo');
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/encoder/audio/PcmS16LeAudioEncoder.ts:
--------------------------------------------------------------------------------
1 | import { AudioEncoder } from '@/ffmpeg/builder/encoder/BaseEncoder.js';
2 |
3 | export class PcmS16LeAudioEncoder extends AudioEncoder {
4 | constructor() {
5 | super('pcm_s16le');
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/encoder/qsv/H264QsvEncoder.ts:
--------------------------------------------------------------------------------
1 | import { VideoFormats } from '@/ffmpeg/builder/constants.js';
2 | import type { Nullable } from '@/types/util.js';
3 | import { isNonEmptyString } from '@/util/index.js';
4 | import { QsvEncoder } from './QsvEncoders.ts';
5 |
6 | export class H264QsvEncoder extends QsvEncoder {
7 | protected videoFormat = VideoFormats.H264;
8 |
9 | constructor(
10 | private videoPreset: Nullable,
11 | private videoProfile: Nullable,
12 | ) {
13 | super('h264_qsv');
14 | }
15 |
16 | options(): string[] {
17 | const opts = [...super.options()];
18 | if (isNonEmptyString(this.videoProfile)) {
19 | opts.push('-profile:v', this.videoProfile);
20 | }
21 | if (isNonEmptyString(this.videoPreset)) {
22 | opts.push('-preset:v', this.videoPreset);
23 | }
24 | return opts;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/encoder/qsv/HevcQsvEncoder.ts:
--------------------------------------------------------------------------------
1 | import { VideoFormats } from '@/ffmpeg/builder/constants.js';
2 | import type { Nullable } from '@/types/util.js';
3 | import { isNonEmptyString } from '@/util/index.js';
4 | import { QsvEncoder } from './QsvEncoders.ts';
5 |
6 | export class HevcQsvEncoder extends QsvEncoder {
7 | protected videoFormat = VideoFormats.Hevc;
8 |
9 | constructor(private videoPreset: Nullable) {
10 | super('hevc_qsv');
11 | }
12 |
13 | options(): string[] {
14 | const opts = [...super.options()];
15 | if (isNonEmptyString(this.videoPreset)) {
16 | opts.push('-preset:v', this.videoPreset);
17 | }
18 | return opts;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/encoder/qsv/Mpeg2QsvEncoder.ts:
--------------------------------------------------------------------------------
1 | import { VideoFormats } from '@/ffmpeg/builder/constants.js';
2 | import { QsvEncoder } from './QsvEncoders.ts';
3 |
4 | export class Mpeg2QsvEncoder extends QsvEncoder {
5 | protected videoFormat = VideoFormats.Mpeg2Video;
6 |
7 | constructor() {
8 | super('mpeg2_qsv');
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/encoder/qsv/QsvEncoders.ts:
--------------------------------------------------------------------------------
1 | import { VideoEncoder } from '@/ffmpeg/builder/encoder/BaseEncoder.js';
2 |
3 | export abstract class QsvEncoder extends VideoEncoder {
4 | protected constructor(name: string) {
5 | super(name);
6 | }
7 |
8 | options(): string[] {
9 | return [...super.options(), '-low_power', '0', '-look_ahead', '0'];
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/AudioPadFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from './FilterOption.ts';
2 |
3 | export class AudioPadFilter extends FilterOption {
4 | constructor(private duration: number) {
5 | super();
6 | }
7 |
8 | static create(duration: number) {
9 | return new AudioPadFilter(duration);
10 | }
11 |
12 | get filter() {
13 | return `apad=whole_dur=${this.duration}ms`;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/FilterChain.ts:
--------------------------------------------------------------------------------
1 | import type { HasFilterOption } from '@/ffmpeg/builder/types/PipelineStep.js';
2 | import type { FilterOption } from './FilterOption.ts';
3 |
4 | export class FilterChain {
5 | videoFilterSteps: HasFilterOption[] = [];
6 | subtitleOverlayFilterSteps: FilterOption[] = [];
7 | watermarkOverlayFilterSteps: FilterOption[] = [];
8 | pixelFormatFilterSteps: HasFilterOption[] = [];
9 | }
10 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/FilterOption.ts:
--------------------------------------------------------------------------------
1 | import type { FrameState } from '@/ffmpeg/builder/state/FrameState.js';
2 | import type {
3 | FilterOptionPipelineStep,
4 | HasFilterOption,
5 | } from '../types/PipelineStep.ts';
6 |
7 | export abstract class FilterOption
8 | implements FilterOptionPipelineStep, HasFilterOption
9 | {
10 | public readonly type = 'filter';
11 |
12 | public readonly affectsFrameState: boolean = false;
13 | public abstract readonly filter: string;
14 |
15 | nextState(currentState: FrameState): FrameState {
16 | return currentState;
17 | }
18 |
19 | options(): string[] {
20 | return [this.filter];
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/ImageScaleFilter.ts:
--------------------------------------------------------------------------------
1 | import type { FrameState } from '../state/FrameState.ts';
2 | import type { FrameSize } from '../types.ts';
3 | import { FrameDataLocation } from '../types.ts';
4 | import { FilterOption } from './FilterOption.ts';
5 |
6 | export class ImageScaleFilter extends FilterOption {
7 | constructor(private scaleSize: FrameSize) {
8 | super();
9 | }
10 |
11 | get filter() {
12 | // TODO: ensure even
13 | return `scale=${this.scaleSize.width}:${this.scaleSize.height}:force_original_aspect_ratio=decrease`;
14 | }
15 |
16 | nextState(currentState: FrameState): FrameState {
17 | return currentState.update({
18 | scaledSize: this.scaleSize,
19 | paddedSize: this.scaleSize,
20 | frameDataLocation: FrameDataLocation.Software,
21 | });
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/LoopFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from './FilterOption.ts';
2 |
3 | export class LoopFilter extends FilterOption {
4 | public filter: string = 'loop=-1:1';
5 | }
6 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/PipelineFilterStep.ts:
--------------------------------------------------------------------------------
1 | import type { IPipelineStep } from '@/ffmpeg/builder/types/PipelineStep.js';
2 |
3 | export interface PipelineFilterStep extends IPipelineStep {
4 | filter: string;
5 | }
6 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/PixelFormatFilter.ts:
--------------------------------------------------------------------------------
1 | import type { PixelFormat } from '@/ffmpeg/builder/format/PixelFormat.js';
2 | import type { FrameState } from '@/ffmpeg/builder/state/FrameState.js';
3 | import { FilterOption } from './FilterOption.ts';
4 |
5 | export class PixelFormatFilter extends FilterOption {
6 | public affectsFrameState: boolean = true;
7 | public readonly filter: string;
8 |
9 | constructor(private pixelFormat: PixelFormat) {
10 | super();
11 | this.filter = `format=${pixelFormat.name}`;
12 | }
13 |
14 | nextState(currentState: FrameState): FrameState {
15 | return currentState.update({
16 | pixelFormat: this.pixelFormat,
17 | });
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/RealtimeFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from './FilterOption.ts';
2 |
3 | export class RealtimeFilter extends FilterOption {
4 | public filter: string = 'realtime';
5 | }
6 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/SineWaveGeneratorFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from './FilterOption.ts';
2 |
3 | export class SineWaveGeneratorFilter extends FilterOption {
4 | constructor(private freq: number = 440) {
5 | super();
6 | }
7 |
8 | get filter(): string {
9 | return `sine=f=${this.freq}`;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/StaticFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from './FilterOption.ts';
2 |
3 | export class StaticFilter extends FilterOption {
4 | public filter: string = 'geq=random(1)*255:128:128';
5 | }
6 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/StreamSeekFilter.ts:
--------------------------------------------------------------------------------
1 | import type { Duration } from 'dayjs/plugin/duration.js';
2 | import type { FrameState } from '../state/FrameState.ts';
3 | import type { FilterOptionPipelineStep } from '../types/PipelineStep.ts';
4 |
5 | export class StreamSeekFilter implements FilterOptionPipelineStep {
6 | readonly type = 'filter';
7 |
8 | constructor(private start: Duration) {}
9 | affectsFrameState: boolean;
10 |
11 | nextState(currentState: FrameState): FrameState {
12 | return currentState;
13 | }
14 |
15 | options(): string[] {
16 | return ['-ss', `${this.start.asMilliseconds()}ms`];
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/SubtitleOverlayFilter.ts:
--------------------------------------------------------------------------------
1 | import type { PixelFormat } from '../format/PixelFormat.ts';
2 | import { FilterOption } from './FilterOption.ts';
3 |
4 | export class SubtitleOverlayFilter extends FilterOption {
5 | constructor(private desiredPixelFormat: PixelFormat) {
6 | super();
7 | }
8 |
9 | get filter() {
10 | return `overlay=x=(W-w)/2:y=(H-h)/2:format=${this.desiredPixelFormat.bitDepth === 10 ? 1 : 0}`;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/nvidia/HardwareUploadCudaFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from '@/ffmpeg/builder/filter/FilterOption.js';
2 | import type { FrameState } from '@/ffmpeg/builder/state/FrameState.js';
3 | import { FrameDataLocation } from '../../types.ts';
4 |
5 | export class HardwareUploadCudaFilter extends FilterOption {
6 | public affectsFrameState: boolean = true;
7 |
8 | constructor(private currentState: FrameState) {
9 | super();
10 | }
11 |
12 | get filter() {
13 | if (this.currentState.frameDataLocation === FrameDataLocation.Hardware) {
14 | return '';
15 | } else {
16 | return 'hwupload_cuda';
17 | }
18 | }
19 |
20 | nextState(currentState: FrameState): FrameState {
21 | return currentState.updateFrameLocation(FrameDataLocation.Hardware);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/nvidia/OverlaySubtitleCudaFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from '../FilterOption.ts';
2 |
3 | export class OverlaySubtitleCudaFilter extends FilterOption {
4 | get filter() {
5 | return `overlay_cuda=x=(W-w)/2:y=(H-h)/2`;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/nvidia/ScaleNppFilter.ts:
--------------------------------------------------------------------------------
1 | import type { FrameState } from '../../state/FrameState.ts';
2 | import type { FrameSize } from '../../types.ts';
3 | import { ScaleCudaFilter } from './ScaleCudaFilter.ts';
4 |
5 | // Going nto take the easy way right now and just extend the Cuda filter
6 | export class ScaleNppFilter extends ScaleCudaFilter {
7 | protected filterName: string = 'scale_npp';
8 |
9 | constructor(
10 | currentState: FrameState,
11 | scaledSize: FrameSize,
12 | paddedSize: FrameSize,
13 | ) {
14 | super(currentState, scaledSize, paddedSize);
15 | this.filter = this.generateFilter();
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/nvidia/SubtitleScaleCudaFilter.ts:
--------------------------------------------------------------------------------
1 | import type { FrameSize } from '../../types.ts';
2 | import { FilterOption } from '../FilterOption.ts';
3 |
4 | export class SubtitleScaleCudaFilter extends FilterOption {
5 | constructor(private targetSize: FrameSize) {
6 | super();
7 | }
8 |
9 | get filter() {
10 | return `scale_cuda=${this.targetSize.width}:${this.targetSize.height}:force_original_aspect_ratio=1`;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/nvidia/SubtitleScaleNppFilter.ts:
--------------------------------------------------------------------------------
1 | import type { FrameSize } from '../../types.ts';
2 | import { FilterOption } from '../FilterOption.ts';
3 |
4 | export class SubtitleScaleNppFilter extends FilterOption {
5 | constructor(private targetSize: FrameSize) {
6 | super();
7 | }
8 |
9 | get filter() {
10 | return `scale_npp=${this.targetSize.width}:${this.targetSize.height}:force_original_aspect_ratio=1`;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/qsv/DeinterlaceQsvFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from '@/ffmpeg/builder/filter/FilterOption.js';
2 | import type { FrameState } from '@/ffmpeg/builder/state/FrameState.js';
3 |
4 | export class DeinterlaceQsvFilter extends FilterOption {
5 | readonly filter: string;
6 |
7 | constructor(currentState: FrameState) {
8 | super();
9 | this.filter = this.generateFilter(currentState);
10 | }
11 |
12 | readonly affectsFrameState: boolean = true;
13 |
14 | nextState(currentState: FrameState): FrameState {
15 | return currentState.update({
16 | deinterlace: false,
17 | frameDataLocation: 'hardware',
18 | });
19 | }
20 |
21 | private generateFilter(currentState: FrameState): string {
22 | const prelude =
23 | currentState.frameDataLocation === 'hardware'
24 | ? 'hwupload=extra_hw_frames=64,'
25 | : '';
26 | return `${prelude}deinterlace_qsv`;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/qsv/HardwareUploadQsvFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from '@/ffmpeg/builder/filter/FilterOption.js';
2 | import type { FrameState } from '@/ffmpeg/builder/state/FrameState.js';
3 | import { FrameDataLocation } from '@/ffmpeg/builder/types.js';
4 |
5 | export class HardwareUploadQsvFilter extends FilterOption {
6 | constructor(private extraHardwareFrames?: number) {
7 | super();
8 | }
9 |
10 | get filter() {
11 | return `hwupload${
12 | this.extraHardwareFrames
13 | ? `=extra_hw_frames=${this.extraHardwareFrames}`
14 | : ''
15 | }`;
16 | }
17 |
18 | nextState(currentState: FrameState): FrameState {
19 | return currentState.updateFrameLocation(FrameDataLocation.Hardware);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/qsv/QsvFormatFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from '@/ffmpeg/builder/filter/FilterOption.js';
2 | import type { PixelFormat } from '@/ffmpeg/builder/format/PixelFormat.js';
3 | import type { FrameState } from '@/ffmpeg/builder/state/FrameState.js';
4 |
5 | export class QsvFormatFilter extends FilterOption {
6 | public affectsFrameState: boolean = true;
7 |
8 | constructor(private pixelFormat: PixelFormat) {
9 | super();
10 | }
11 |
12 | get filter() {
13 | return `vpp_qsv=format=${this.pixelFormat.name}`;
14 | }
15 |
16 | nextState(currentState: FrameState): FrameState {
17 | return currentState.update({ pixelFormat: this.pixelFormat });
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/vaapi/DeinterlaceVaapiFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from '@/ffmpeg/builder/filter/FilterOption.js';
2 | import type { FrameState } from '@/ffmpeg/builder/state/FrameState.js';
3 |
4 | export class DeinterlaceVaapiFilter extends FilterOption {
5 | public get filter(): string {
6 | return this.currentState.frameDataLocation === 'hardware'
7 | ? 'deinterlace_vaapi'
8 | : 'format=nv12|p010le|vaapi,hwupload,deinterlace_vaapi';
9 | }
10 |
11 | constructor(private currentState: FrameState) {
12 | super();
13 | }
14 |
15 | public readonly affectsFrameState: boolean = true;
16 |
17 | nextState(currentState: FrameState): FrameState {
18 | return currentState.update({
19 | deinterlace: true,
20 | frameDataLocation: 'hardware',
21 | });
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/vaapi/HardwareUploadVaapiFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from '@/ffmpeg/builder/filter/FilterOption.js';
2 | import type { FrameState } from '@/ffmpeg/builder/state/FrameState.js';
3 | import { FrameDataLocation } from '@/ffmpeg/builder/types.js';
4 |
5 | export class HardwareUploadVaapiFilter extends FilterOption {
6 | constructor(
7 | private setFormat: boolean,
8 | private extraHardwareFrames?: number,
9 | ) {
10 | super();
11 | }
12 |
13 | get filter() {
14 | const format = this.setFormat ? 'format=nv12|p010le|vaapi,' : '';
15 | return `${format}hwupload${
16 | this.extraHardwareFrames
17 | ? `=extra_hw_frames=${this.extraHardwareFrames}`
18 | : ''
19 | }`;
20 | }
21 |
22 | nextState(currentState: FrameState): FrameState {
23 | return currentState.updateFrameLocation(FrameDataLocation.Hardware);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/vaapi/ScaleSubtitlesVaapiFilter.ts:
--------------------------------------------------------------------------------
1 | import type { FrameSize } from '../../types.ts';
2 | import { FilterOption } from '../FilterOption.ts';
3 |
4 | export class ScaleSubtitlesVaapiFilter extends FilterOption {
5 | constructor(private size: FrameSize) {
6 | super();
7 | }
8 |
9 | get filter() {
10 | return `scale_vaapi=${this.size.width}:${this.size.height}:force_original_aspect_ratio=decrease`;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/vaapi/VaapiFormatFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from '@/ffmpeg/builder/filter/FilterOption.js';
2 | import type { PixelFormat } from '@/ffmpeg/builder/format/PixelFormat.js';
3 | import type { FrameState } from '@/ffmpeg/builder/state/FrameState.js';
4 |
5 | export class VaapiFormatFilter extends FilterOption {
6 | constructor(
7 | private pixelFormat: PixelFormat,
8 | private extraHardwareFrames: number = 64,
9 | ) {
10 | super();
11 | }
12 |
13 | get filter() {
14 | return `scale_vaapi=format=${this.hardarePixelFormat.name}:extra_hw_frames=${this.extraHardwareFrames}`;
15 | }
16 |
17 | nextState(currentState: FrameState): FrameState {
18 | return currentState.update({ pixelFormat: this.hardarePixelFormat });
19 | }
20 |
21 | private get hardarePixelFormat() {
22 | return this.pixelFormat.toHardwareFormat() ?? this.pixelFormat;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/vaapi/VaapiOverlayFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from '../FilterOption.ts';
2 |
3 | export class VaapiOverlayFilter extends FilterOption {
4 | get filter() {
5 | return `overlay_vaapi=x=(W-w)/2:y=(H-h)/2`;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/vaapi/VaapiSubtitlePixelFormatFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from '../FilterOption.ts';
2 |
3 | export class VaapiSubtitlePixelFormatFilter extends FilterOption {
4 | get filter() {
5 | return 'format=vaapi|yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8';
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/videotoolbox/VideoToolboxHardwareAccelerationOption.ts:
--------------------------------------------------------------------------------
1 | import { GlobalOption } from '@/ffmpeg/builder/options/GlobalOption.js';
2 |
3 | export class VideoToolboxHardwareAccelerationOption extends GlobalOption {
4 | options(): string[] {
5 | return ['-hwaccel', 'videotoolbox'];
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/watermark/WatermarkFadeFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from '../FilterOption.ts';
2 |
3 | export class WatermarkFadeFilter extends FilterOption {
4 | constructor(
5 | private fadeIn: boolean,
6 | private startSecondsd: number,
7 | private enableStart: number,
8 | private enableEnd: number,
9 | ) {
10 | super();
11 | }
12 |
13 | get filter() {
14 | const inOut = this.fadeIn ? 'in' : 'out';
15 | return `fade=${inOut}:st=${this.startSecondsd}:d=1:alpha=1:enable='between(t,${this.enableStart},${this.enableEnd})'`;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/watermark/WatermarkOpacityFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from '@/ffmpeg/builder/filter/FilterOption.js';
2 | import { round } from 'lodash-es';
3 |
4 | export class WatermarkOpacityFilter extends FilterOption {
5 | constructor(
6 | private opacity: number,
7 | private between?: { startSeconds: number; endSeconds: number },
8 | ) {
9 | super();
10 | }
11 |
12 | get filter() {
13 | const opacity =
14 | this.opacity > 2
15 | ? round(this.opacity / 100.0, 2)
16 | : round(this.opacity, 2);
17 | let enable = '';
18 | if (this.between) {
19 | enable = `:enable='between(t,${this.between.startSeconds},${this.between.endSeconds})'`;
20 | }
21 | return `format=yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,colorchannelmixer=aa=${opacity}${enable}`;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/filter/watermark/WatermarkScaleFilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterOption } from '@/ffmpeg/builder/filter/FilterOption.js';
2 | import type { FrameSize } from '@/ffmpeg/builder/types.js';
3 | import type { Watermark } from '@tunarr/types';
4 |
5 | export class WatermarkScaleFilter extends FilterOption {
6 | constructor(
7 | private resolution: FrameSize,
8 | private watermark: Watermark,
9 | ) {
10 | super();
11 | }
12 |
13 | public get filter(): string {
14 | const width = Math.round(
15 | (this.watermark.width / 100) * this.resolution.width,
16 | );
17 | return `scale=${width}:-1`;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/input/SubtitlesInputSource.ts:
--------------------------------------------------------------------------------
1 | import type { SubtitleMethod, SubtitleStream } from '../MediaStream.ts';
2 | import { FrameDataLocation } from '../types.ts';
3 | import type { InputSourceType, StreamSource } from './InputSource.ts';
4 | import { InputSource } from './InputSource.ts';
5 |
6 | export class SubtitlesInputSource extends InputSource {
7 | readonly type: InputSourceType = 'video';
8 | #frameDataLocation: FrameDataLocation = FrameDataLocation.Unknown;
9 |
10 | constructor(
11 | source: StreamSource,
12 | public streams: SubtitleStream[],
13 | public method: SubtitleMethod,
14 | ) {
15 | super(source, 'discrete');
16 | }
17 |
18 | get frameDataLocation() {
19 | return this.#frameDataLocation;
20 | }
21 |
22 | set frameDataLocation(location: FrameDataLocation) {
23 | this.#frameDataLocation = location;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/input/WatermarkInputSource.ts:
--------------------------------------------------------------------------------
1 | import type { StillImageStream } from '@/ffmpeg/builder/MediaStream.js';
2 | import type { Watermark } from '@tunarr/types';
3 | import type { StreamSource } from './InputSource.ts';
4 | import { VideoInputSource } from './VideoInputSource.ts';
5 |
6 | export class WatermarkInputSource extends VideoInputSource {
7 | constructor(
8 | source: StreamSource,
9 | imageStream: StillImageStream,
10 | public watermark: Watermark,
11 | ) {
12 | super(source, [imageStream]);
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/options/EnvironmentVariables.ts:
--------------------------------------------------------------------------------
1 | import type { EnvironmentVariablePipelineStep } from '@/ffmpeg/builder/types/PipelineStep.js';
2 | import type { Dictionary } from 'ts-essentials';
3 |
4 | export abstract class EnvironmentVariable
5 | implements EnvironmentVariablePipelineStep
6 | {
7 | readonly type = 'environment';
8 |
9 | options(): Dictionary {
10 | return {};
11 | }
12 | }
13 |
14 | export class VaapiDriverEnvironmentVariable extends EnvironmentVariable {
15 | constructor(private vaapiDriver: string) {
16 | super();
17 | }
18 |
19 | options(): Dictionary {
20 | return {
21 | LIBVA_DRIVER_NAME: this.vaapiDriver,
22 | };
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/options/KnownFfmpegOptions.ts:
--------------------------------------------------------------------------------
1 | export const KnownFfmpegOptions = {
2 | ReadrateInitialBurst: 'readrate_initial_burst',
3 | GpuCopy: 'gpu_copy',
4 | } as const;
5 |
6 | export const KnownFfmpegFilters = {
7 | ScaleNpp: 'scale_npp',
8 | ScaleCuda: 'scale_cuda',
9 | };
10 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/options/LogLevelOption.ts:
--------------------------------------------------------------------------------
1 | import { type FfmpegLogLevel } from '@tunarr/types/schemas';
2 | import { ConstantGlobalOption } from './GlobalOption.ts';
3 |
4 | export class LogLevelOption extends ConstantGlobalOption {
5 | constructor(level: FfmpegLogLevel = 'error' as const) {
6 | super(['-loglevel', level]);
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/options/NoStatsOption.ts:
--------------------------------------------------------------------------------
1 | import { ConstantGlobalOption } from './GlobalOption.ts';
2 |
3 | export class NoStatsOption extends ConstantGlobalOption {
4 | constructor() {
5 | super(['-nostats']);
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/options/hardwareAcceleration/ExtraHardwareFramesOption.ts:
--------------------------------------------------------------------------------
1 | import { GlobalOption } from '@/ffmpeg/builder/options/GlobalOption.js';
2 |
3 | export class ExtraHardwareFramesOption extends GlobalOption {
4 | constructor(private numFrames: number = 64) {
5 | super();
6 | }
7 |
8 | options(): string[] {
9 | return ['-extra_hw_frames', `${this.numFrames}`];
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/options/hardwareAcceleration/NvidiaOptions.ts:
--------------------------------------------------------------------------------
1 | import { ConstantGlobalOption } from '@/ffmpeg/builder/options/GlobalOption.js';
2 |
3 | export class CudaHardwareAccelerationOption extends ConstantGlobalOption {
4 | constructor() {
5 | super(['-hwaccel', 'cuda']);
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/options/hardwareAcceleration/VaapiOptions.ts:
--------------------------------------------------------------------------------
1 | import { GlobalOption } from '@/ffmpeg/builder/options/GlobalOption.js';
2 | import type { FrameState } from '@/ffmpeg/builder/state/FrameState.js';
3 | import { FrameDataLocation } from '@/ffmpeg/builder/types.js';
4 |
5 | export class VaapiHardwareAccelerationOption extends GlobalOption {
6 | constructor(
7 | private vaapiDevice: string,
8 | private canHardwardDecode: boolean,
9 | ) {
10 | super();
11 | }
12 |
13 | options(): string[] {
14 | return this.canHardwardDecode
15 | ? ['-hwaccel', 'vaapi', '-vaapi_device', this.vaapiDevice]
16 | : ['-vaapi_device', this.vaapiDevice];
17 | }
18 |
19 | nextState(currentState: FrameState): FrameState {
20 | return currentState.updateFrameLocation(FrameDataLocation.Hardware);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/options/input/ConcatHttpReconnectOptions.ts:
--------------------------------------------------------------------------------
1 | import type { MediaStream } from '@/ffmpeg/builder/MediaStream.js';
2 | import type { InputSource } from '@/ffmpeg/builder/input/InputSource.js';
3 | import { InputOption } from '@/ffmpeg/builder/options/input/InputOption.js';
4 |
5 | export class ConcatHttpReconnectOptions extends InputOption {
6 | appliesToInput(input: InputSource): boolean {
7 | return input.protocol === 'http' && input.continuity === 'infinite';
8 | }
9 |
10 | options(): string[] {
11 | return ['-reconnect', '1', '-reconnect_at_eof', '1'];
12 | }
13 |
14 | affectsFrameState: boolean = false;
15 | }
16 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/options/input/ConcatInputFormatOption.ts:
--------------------------------------------------------------------------------
1 | import type { MediaStream } from '@/ffmpeg/builder/MediaStream.js';
2 | import { ConcatInputSource } from '@/ffmpeg/builder/input/ConcatInputSource.js';
3 | import type { InputSource } from '@/ffmpeg/builder/input/InputSource.js';
4 | import { InputOption } from './InputOption.ts';
5 |
6 | export class ConcatInputFormatOption extends InputOption {
7 | appliesToInput(input: InputSource): boolean {
8 | return input instanceof ConcatInputSource;
9 | }
10 |
11 | options(): string[] {
12 | return [
13 | '-f',
14 | 'concat',
15 | '-safe',
16 | '0',
17 | '-protocol_whitelist',
18 | 'file,http,tcp,https,tls',
19 | '-probesize',
20 | '32',
21 | ];
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/options/input/CopyTimestampInputOption.ts:
--------------------------------------------------------------------------------
1 | import type { InputSource } from '../../input/InputSource.ts';
2 | import { InputOption } from './InputOption.ts';
3 |
4 | export class CopyTimestampInputOption extends InputOption {
5 | appliesToInput(input: InputSource): boolean {
6 | return input.type === 'video';
7 | }
8 |
9 | options(_inputSource: InputSource): string[] {
10 | return ['-copyts'];
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/options/input/DoNotIgnoreLoopInputOption.ts:
--------------------------------------------------------------------------------
1 | import type { InputSource } from '@/ffmpeg/builder/input/InputSource.js';
2 | import { every } from 'lodash-es';
3 | import { InputOption } from './InputOption.ts';
4 |
5 | export class DoNotIgnoreLoopInputOption extends InputOption {
6 | options(): string[] {
7 | return ['-ignore_loop', '0'];
8 | }
9 |
10 | appliesToInput(input: InputSource): boolean {
11 | return (
12 | input.isVideo() &&
13 | every(input.streams, (stream) => stream.inputKind === 'stillimage')
14 | );
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/options/input/HttpHeadersInputOption.ts:
--------------------------------------------------------------------------------
1 | import type { MediaStream } from '@/ffmpeg/builder/MediaStream.js';
2 | import type { InputSource } from '@/ffmpeg/builder/input/InputSource.js';
3 | import { isEmpty } from 'lodash-es';
4 | import { InputOption } from './InputOption.ts';
5 |
6 | export class HttpHeadersInputOption extends InputOption {
7 | appliesToInput(input: InputSource): boolean {
8 | return input.source.type === 'http' && !isEmpty(input.source.extraHeaders);
9 | }
10 |
11 | options(inputSource: InputSource): string[] {
12 | const opts: string[] = [];
13 | if (inputSource.source.type !== 'http') {
14 | return opts;
15 | }
16 |
17 | for (const [key, value] of Object.entries(
18 | inputSource.source.extraHeaders,
19 | )) {
20 | opts.push('-headers', `'${key}: ${value}'`);
21 | }
22 |
23 | return opts;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/options/input/HttpReconnectOptions.ts:
--------------------------------------------------------------------------------
1 | import type { MediaStream } from '@/ffmpeg/builder/MediaStream.js';
2 | import type { InputSource } from '@/ffmpeg/builder/input/InputSource.js';
3 | import { InputOption } from './InputOption.ts';
4 |
5 | export class HttpReconnectOptions extends InputOption {
6 | appliesToInput(input: InputSource): boolean {
7 | return input.protocol === 'http' && input.continuity === 'discrete';
8 | }
9 |
10 | options(): string[] {
11 | return [
12 | '-reconnect',
13 | '1',
14 | '-reconnect_on_network_error',
15 | '1',
16 | '-reconnect_streamed',
17 | '1',
18 | '-multiple_requests',
19 | '1',
20 | ];
21 | }
22 |
23 | affectsFrameState: boolean = false;
24 | }
25 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/options/input/InputOption.ts:
--------------------------------------------------------------------------------
1 | import type { InputSource } from '@/ffmpeg/builder/input/InputSource.js';
2 | import type { FrameState } from '@/ffmpeg/builder/state/FrameState.js';
3 | import type { InputOptionPipelineStep } from '@/ffmpeg/builder/types/PipelineStep.js';
4 |
5 | export abstract class InputOption implements InputOptionPipelineStep {
6 | readonly type = 'input';
7 |
8 | readonly affectsFrameState: boolean = false;
9 |
10 | abstract appliesToInput(input: InputSource): boolean;
11 |
12 | abstract options(inputSource: InputSource): string[];
13 |
14 | nextState(currentState: FrameState) {
15 | return currentState;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/options/input/LavfiInputOption.ts:
--------------------------------------------------------------------------------
1 | import type { MediaStream } from '@/ffmpeg/builder/MediaStream.js';
2 | import type { InputSource } from '@/ffmpeg/builder/input/InputSource.js';
3 | import { InputOption } from './InputOption.ts';
4 |
5 | export class LavfiInputOption extends InputOption {
6 | options(): string[] {
7 | return ['-f', 'lavfi'];
8 | }
9 |
10 | appliesToInput(input: InputSource): boolean {
11 | return input.type === 'video' || input.type === 'audio';
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/options/input/StreamSeekInputOption.ts:
--------------------------------------------------------------------------------
1 | import type { MediaStream } from '@/ffmpeg/builder/MediaStream.js';
2 | import type { InputSource } from '@/ffmpeg/builder/input/InputSource.js';
3 | import type { Duration } from 'dayjs/plugin/duration.js';
4 | import { some } from 'lodash-es';
5 | import { InputOption } from './InputOption.ts';
6 |
7 | export class StreamSeekInputOption extends InputOption {
8 | constructor(private start: Duration) {
9 | super();
10 | }
11 |
12 | appliesToInput(input: InputSource): boolean {
13 | if (!input.isVideo()) {
14 | return true;
15 | }
16 |
17 | return !some(input.streams, (stream) => stream.inputKind === 'stillimage');
18 | }
19 |
20 | options() {
21 | return ['-ss', `${this.start.asMilliseconds()}ms`];
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/options/input/UserAgentInputOption.ts:
--------------------------------------------------------------------------------
1 | import type { MediaStream } from '@/ffmpeg/builder/MediaStream.js';
2 | import type { InputSource } from '@/ffmpeg/builder/input/InputSource.js';
3 | import { InputOption } from './InputOption.ts';
4 |
5 | export class UserAgentInputOption extends InputOption {
6 | constructor(private userAgent: string) {
7 | super();
8 | }
9 |
10 | appliesToInput(input: InputSource): boolean {
11 | return input.protocol === 'http';
12 | }
13 |
14 | options(): string[] {
15 | return ['-user_agent', `${this.userAgent}`];
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/pipeline/PipelineInputs.ts:
--------------------------------------------------------------------------------
1 | import type { AudioInputSource } from '@/ffmpeg/builder/input/AudioInputSource.js';
2 | import type { ConcatInputSource } from '@/ffmpeg/builder/input/ConcatInputSource.js';
3 | import type { VideoInputSource } from '@/ffmpeg/builder/input/VideoInputSource.js';
4 | import type { WatermarkInputSource } from '@/ffmpeg/builder/input/WatermarkInputSource.js';
5 | import type { Nullable } from '@/types/util.js';
6 |
7 | export type PipelineInputs = {
8 | videoInput: Nullable;
9 | audioInput: Nullable;
10 | watermarkInput: Nullable;
11 | concatInput: Nullable;
12 | };
13 |
--------------------------------------------------------------------------------
/server/src/ffmpeg/builder/types/FrameSize.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/server/src/ffmpeg/builder/types/FrameSize.ts
--------------------------------------------------------------------------------
/server/src/interfaces/ITimer.ts:
--------------------------------------------------------------------------------
1 | import type { LogLevels } from '../util/logging/LoggerFactory.ts';
2 |
3 | export interface ITimer {
4 | timeSync(name: string, f: () => T, opts: { level: LogLevels }): T;
5 |
6 | timeAsync(
7 | name: string,
8 | f: () => Promise,
9 | opts: { level: LogLevels },
10 | ): Promise;
11 | }
12 |
--------------------------------------------------------------------------------
/server/src/migration/Migration.ts:
--------------------------------------------------------------------------------
1 | export interface Migration<
2 | Schema,
3 | FromVersion extends number,
4 | ToVersion extends number,
5 | > {
6 | from: FromVersion;
7 | to: ToVersion;
8 | migrate(schema: Schema): Promise;
9 | }
10 |
--------------------------------------------------------------------------------
/server/src/migration/db/LegacyMigration15.ts:
--------------------------------------------------------------------------------
1 | import type { Kysely } from 'kysely';
2 | import { sql } from 'kysely';
3 |
4 | export default {
5 | async up(db: Kysely): Promise {
6 | await db.schema
7 | .alterTable('channel')
8 | .addColumn('stream_mode', 'text', (b) =>
9 | b
10 | .check(sql`(\`stream_mode\`) in ('hls', 'hls_slower', 'mpegts')`)
11 | .notNull()
12 | .defaultTo('hls'),
13 | )
14 | .execute();
15 | // this.addSql(
16 | // "alter table `channel` add column `stream_mode` text check (`stream_mode` in ('hls', 'hls_slower', 'mpegts')) not null default 'hls';",
17 | // );
18 | },
19 |
20 | async down(db: Kysely): Promise {
21 | // this.addSql('alter table `channel` drop column `stream_mode`;');
22 | await db.schema.alterTable('channel').dropColumn('stream_mode').execute();
23 | },
24 | };
25 |
--------------------------------------------------------------------------------
/server/src/migration/db/LegacyMigration3.ts:
--------------------------------------------------------------------------------
1 | import type { Kysely } from 'kysely';
2 |
3 | export default {
4 | async up(db: Kysely): Promise {
5 | await db.schema
6 | .alterTable('plex_server_settings')
7 | .addColumn('client_identifier', 'text')
8 | .execute();
9 | },
10 |
11 | async down(db: Kysely): Promise {
12 | await db.schema
13 | .alterTable('plex_server_settings')
14 | .dropColumn('client_identifier')
15 | .execute();
16 | },
17 | };
18 |
--------------------------------------------------------------------------------
/server/src/migration/db/LegacyMigration6.ts:
--------------------------------------------------------------------------------
1 | import type { DB } from '@/db/schema/db.js';
2 | import type { Kysely, Migration } from 'kysely';
3 |
4 | export default {
5 | async up(db: Kysely): Promise {
6 | await db
7 | .updateTable('program')
8 | .set(({ ref }) => ({
9 | externalKey: ref('plexRatingKey').$notNull(),
10 | }))
11 | .where('plexRatingKey', 'is not', null)
12 | .execute();
13 | },
14 | } satisfies Migration;
15 |
--------------------------------------------------------------------------------
/server/src/migration/db/LegacyMigration7.ts:
--------------------------------------------------------------------------------
1 | import type { Kysely, Migration } from 'kysely';
2 | import { columnExists } from './util.ts';
3 |
4 | export default {
5 | async up(db: Kysely): Promise {
6 | await db.schema
7 | .alterTable('channel')
8 | .addColumn('guide_flex_title', 'text')
9 | .execute();
10 | },
11 | async down(db) {
12 | if (await columnExists(db, 'channel', 'guide_flex_title')) {
13 | await db.schema
14 | .alterTable('channel')
15 | .dropColumn('guide_flex_title')
16 | .execute();
17 | }
18 | },
19 | } satisfies Migration;
20 |
--------------------------------------------------------------------------------
/server/src/migration/db/LegacyMigration8.ts:
--------------------------------------------------------------------------------
1 | import type { Kysely, Migration } from 'kysely';
2 |
3 | export default {
4 | async up(db: Kysely): Promise {
5 | await db.schema
6 | .alterTable('program')
7 | .renameColumn('season', 'season_number')
8 | .execute();
9 | },
10 | async down(db) {
11 | await db.schema
12 | .alterTable('channel')
13 | .renameColumn('season_number', 'season')
14 | .execute();
15 | },
16 | } satisfies Migration;
17 |
--------------------------------------------------------------------------------
/server/src/migration/db/Migration1741297998_AddProgramIndexes.ts:
--------------------------------------------------------------------------------
1 | import type { Kysely } from 'kysely';
2 |
3 | export default {
4 | async up(db: Kysely) {
5 | await db.schema
6 | .createIndex('program_external_id_program_uuid_index')
7 | .ifNotExists()
8 | .on('program_external_id')
9 | .column('program_uuid')
10 | .execute();
11 | await db.schema
12 | .createIndex('program_grouping_external_id_group_uuid_index')
13 | .ifNotExists()
14 | .on('program_grouping_external_id')
15 | .column('group_uuid')
16 | .execute();
17 | },
18 | };
19 |
--------------------------------------------------------------------------------
/server/src/migration/db/Migration1741658292_MediaSourceIndex.ts:
--------------------------------------------------------------------------------
1 | import type { Kysely, Migration } from 'kysely';
2 |
3 | export default {
4 | async up(db: Kysely) {
5 | await db.schema
6 | .createIndex('media_source_type_name_uri_unique')
7 | .ifNotExists()
8 | .on('media_source')
9 | .columns(['type', 'name', 'uri'])
10 | .unique()
11 | .execute();
12 | },
13 | } satisfies Migration;
14 |
--------------------------------------------------------------------------------
/server/src/migration/db/Migration1746123876_ReworkSubtitleFilter.ts:
--------------------------------------------------------------------------------
1 | import type { TunarrDatabaseMigration } from '../DirectMigrationProvider.ts';
2 | import { applyDrizzleMigrationExpression } from './util.ts';
3 |
4 | const expr = String.raw`
5 | ALTER TABLE "channel" ADD "subtitles_enabled" integer DEFAULT false;--> statement-breakpoint
6 | ALTER TABLE "channel" DROP COLUMN "subtitle_filter";--> statement-breakpoint
7 | ALTER TABLE "channel_subtitle_preferences" ADD "filter_type" text DEFAULT 'any';--> statement-breakpoint
8 | ALTER TABLE "custom_show_subtitle_preferences" ADD "filter_type" text NOT NULL DEFAULT 'any';
9 | `;
10 |
11 | export default {
12 | fullCopy: true,
13 | async up(db) {
14 | await applyDrizzleMigrationExpression(db, expr);
15 | },
16 | } satisfies TunarrDatabaseMigration;
17 |
--------------------------------------------------------------------------------
/server/src/migration/db/util.ts:
--------------------------------------------------------------------------------
1 | import { CompiledQuery, type Kysely } from 'kysely';
2 | import { isNonEmptyString } from '../../util/index.ts';
3 |
4 | export async function columnExists(
5 | db: Kysely,
6 | tableName: string,
7 | colName: string,
8 | ): Promise {
9 | const tables = await db.introspection.getTables();
10 | return (
11 | tables
12 | .find((table) => table.name === tableName)
13 | ?.columns.some((col) => col.name === colName) ?? false
14 | );
15 | }
16 |
17 | export async function applyDrizzleMigrationExpression(
18 | db: Kysely,
19 | exprString: string,
20 | breakpoint: string = '--> statement-breakpoint',
21 | ) {
22 | const queries = exprString
23 | .split(breakpoint)
24 | .map((s) => s.trim())
25 | .filter(isNonEmptyString)
26 | .map((s) => CompiledQuery.raw(s));
27 |
28 | for (const query of queries) {
29 | await db.executeQuery(query);
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/server/src/migration/lineups/ChannelLineupMigration.ts:
--------------------------------------------------------------------------------
1 | import type { Lineup } from '@/db/derived_types/Lineup.js';
2 | import type { IChannelDB } from '@/db/interfaces/IChannelDB.js';
3 | import type { IProgramDB } from '@/db/interfaces/IProgramDB.js';
4 | import type { Migration } from '@/migration/Migration.js';
5 |
6 | export abstract class ChannelLineupMigration<
7 | From extends number,
8 | To extends number,
9 | > implements Migration
10 | {
11 | constructor(
12 | protected channelDB: IChannelDB,
13 | protected programDB: IProgramDB,
14 | ) {}
15 |
16 | abstract from: From;
17 | abstract to: To;
18 | abstract migrate(schema: Lineup): Promise;
19 | }
20 |
--------------------------------------------------------------------------------
/server/src/resources/images/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/server/src/resources/images/favicon.ico
--------------------------------------------------------------------------------
/server/src/resources/images/generic-error-screen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/server/src/resources/images/generic-error-screen.png
--------------------------------------------------------------------------------
/server/src/resources/images/generic-music-screen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/server/src/resources/images/generic-music-screen.png
--------------------------------------------------------------------------------
/server/src/resources/images/generic-offline-screen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/server/src/resources/images/generic-offline-screen.png
--------------------------------------------------------------------------------
/server/src/resources/images/loading-screen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/server/src/resources/images/loading-screen.png
--------------------------------------------------------------------------------
/server/src/resources/images/tunarr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/server/src/resources/images/tunarr.png
--------------------------------------------------------------------------------
/server/src/services/FileSystemService.ts:
--------------------------------------------------------------------------------
1 | import { inject, injectable } from 'inversify';
2 | import path from 'path';
3 | import { GlobalOptions } from '../globals.ts';
4 | import { KEYS } from '../types/inject.ts';
5 | import {
6 | CacheFolderName,
7 | SubtitlesCacheFolderName,
8 | } from '../util/constants.ts';
9 |
10 | @injectable()
11 | export class FileSystemService {
12 | constructor(
13 | @inject(KEYS.GlobalOptions) private globalOptions: GlobalOptions,
14 | ) {}
15 |
16 | getSubtitleCacheFolder() {
17 | return path.join(
18 | this.globalOptions.databaseDirectory,
19 | CacheFolderName,
20 | SubtitlesCacheFolderName,
21 | );
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/server/src/services/StartupService.ts:
--------------------------------------------------------------------------------
1 | import { inject, injectable } from 'inversify';
2 | import { KEYS } from '../types/inject.ts';
3 | import type { Logger } from '../util/logging/LoggerFactory.ts';
4 | import { SystemDevicesService } from './SystemDevicesService.ts';
5 |
6 | @injectable()
7 | export class StartupService {
8 | #hasRun = false;
9 |
10 | constructor(
11 | @inject(KEYS.Logger) private logger: Logger,
12 | @inject(SystemDevicesService)
13 | private systemDevicesService: SystemDevicesService,
14 | ) {}
15 |
16 | async runStartupServices() {
17 | if (!this.#hasRun) {
18 | try {
19 | await Promise.all([this.systemDevicesService.seed()]);
20 | } catch (e) {
21 | this.logger.fatal(
22 | e,
23 | 'Error when running startup services! The system might not function normally.',
24 | );
25 | }
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/server/src/services/dynamic_channels/CollapseOfflineTimeOperator.test.ts:
--------------------------------------------------------------------------------
1 | import { LineupItem } from '@/db/derived_types/Lineup.js';
2 | import { map, random, range, sumBy } from 'lodash-es';
3 | import { collapseOfflineTime } from './CollapseOfflineTimeOperator.js';
4 |
5 | describe('CollapseOfflineTimeOperator', () => {
6 | test('collapses offline time', async () => {
7 | const offlineLineup: LineupItem[] = map(range(0, 5), (i) => ({
8 | type: 'offline',
9 | durationMs: random(0, 50, false),
10 | }));
11 | const expectedDuration = sumBy(offlineLineup, (l) => l.durationMs);
12 | const newLineup = await collapseOfflineTime({ items: offlineLineup });
13 | expect(newLineup.items).toHaveLength(1);
14 | expect(newLineup.items[0].durationMs).toEqual(expectedDuration);
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/server/src/services/dynamic_channels/CompositeOperator.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/server/src/services/dynamic_channels/CompositeOperator.ts
--------------------------------------------------------------------------------
/server/src/services/dynamic_channels/RandomSortOperator.ts:
--------------------------------------------------------------------------------
1 | import type { ChannelAndLineup } from '@/types/internal.js';
2 | import { random } from '@/util/random.js';
3 | import type { RandomSortOrderOperation } from '@tunarr/types/api';
4 | import { SchedulingOperator } from './SchedulingOperator.ts';
5 |
6 | export class RandomSortOperator extends SchedulingOperator {
7 | public apply({
8 | channel,
9 | lineup,
10 | }: ChannelAndLineup): Promise {
11 | const newLineup = random.shuffle([...lineup.items]);
12 | return Promise.resolve({
13 | channel,
14 | lineup: { ...lineup, items: newLineup },
15 | });
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/server/src/services/dynamic_channels/SchedulingOperator.ts:
--------------------------------------------------------------------------------
1 | import type { ChannelAndLineup } from '@/types/internal.js';
2 | import type { SchedulingOperation } from '@tunarr/types/api';
3 |
4 | // A SchedulingOperator takes a set of lineup items
5 | // and returns a set of lineup items. The operator
6 | // can sort, add, remove, etc, but it must not mutate
7 | // the incoming array.
8 | export abstract class SchedulingOperator<
9 | ConfigType extends SchedulingOperation,
10 | > {
11 | protected config: ConfigType;
12 |
13 | constructor(config: ConfigType) {
14 | this.config = config;
15 | }
16 |
17 | public abstract apply(
18 | channelAndLineup: ChannelAndLineup,
19 | ): Promise;
20 | }
21 |
--------------------------------------------------------------------------------
/server/src/services/health_checks/HealthCheck.ts:
--------------------------------------------------------------------------------
1 | export type HealthyCheckResult = {
2 | type: 'healthy';
3 | context?: string;
4 | };
5 |
6 | export const HealthyHealthCheckResult: HealthyCheckResult = { type: 'healthy' };
7 |
8 | export type NonHealthyCheckResult = {
9 | type: 'info' | 'warning' | 'error';
10 | context: string;
11 | };
12 |
13 | export type HealthCheckResult = HealthyCheckResult | NonHealthyCheckResult;
14 |
15 | export function healthCheckResult(result: HealthCheckResult): typeof result {
16 | return result;
17 | }
18 |
19 | export interface HealthCheck {
20 | id: string;
21 | getStatus(): Promise;
22 | }
23 |
--------------------------------------------------------------------------------
/server/src/services/interfaces/IFillerPicker.ts:
--------------------------------------------------------------------------------
1 | import type { Channel } from '../../db/schema/Channel.ts';
2 | import type {
3 | ChannelFillerShowWithContent,
4 | ProgramWithRelations,
5 | } from '../../db/schema/derivedTypes.js';
6 | import type { Nullable } from '../../types/util.ts';
7 |
8 | export type FillerPickResult = {
9 | fillerListId: Nullable;
10 | filler: Nullable;
11 | minimumWait: number;
12 | };
13 |
14 | export const EmptyFillerPickResult: FillerPickResult = {
15 | filler: null,
16 | fillerListId: null,
17 | minimumWait: Number.MAX_SAFE_INTEGER,
18 | };
19 |
20 | export interface IFillerPicker {
21 | pickFiller(
22 | channel: Channel,
23 | fillers: ChannelFillerShowWithContent[],
24 | maxDuration: number,
25 | ): FillerPickResult;
26 | }
27 |
--------------------------------------------------------------------------------
/server/src/services/interfaces/IOnDemandChannelService.ts:
--------------------------------------------------------------------------------
1 | export interface IOnDemandChannelService {}
2 |
--------------------------------------------------------------------------------
/server/src/stream/ProgramStreamFactory.ts:
--------------------------------------------------------------------------------
1 | import type { OutputFormat } from '@/ffmpeg/builder/constants.js';
2 | import type { PlayerContext } from './PlayerStreamContext.js';
3 | import type { ProgramStream } from './ProgramStream.js';
4 |
5 | /**
6 | * Creates a {@link ProgramStream} baased on the given context
7 | */
8 | export type ProgramStreamFactory = (
9 | context: PlayerContext,
10 | outputFormat: OutputFormat,
11 | ) => ProgramStream;
12 |
--------------------------------------------------------------------------------
/server/src/stream/hls/HlsPlaylistMutator.test.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs';
2 | import fs from 'node:fs/promises';
3 | import path from 'node:path';
4 | import { HlsPlaylistMutator } from './HlsPlaylistMutator';
5 |
6 | test('HlsPlaylistMutator', async () => {
7 | const mutator = new HlsPlaylistMutator();
8 | const lines = (
9 | await fs.readFile(
10 | path.join(process.cwd(), 'src', 'testing', 'resources', 'test.m3u8'),
11 | )
12 | )
13 | .toString('utf-8')
14 | .split('\n');
15 | const start = dayjs('2024-10-18T14:01:55.164-0400');
16 |
17 | const newPlaylist = mutator.trimPlaylist(
18 | start,
19 | start.subtract(30, 'seconds'),
20 | lines,
21 | 10,
22 | true,
23 | );
24 |
25 | console.log(
26 | mutator.parsePlaylist(start, newPlaylist.playlist.split('\n'), true),
27 | );
28 | });
29 |
--------------------------------------------------------------------------------
/server/src/tasks/OneOffTask.ts:
--------------------------------------------------------------------------------
1 | import type { TaskFactoryFn } from './ScheduledTask.ts';
2 | import { ScheduledTask } from './ScheduledTask.ts';
3 |
4 | export class OneOffTask<
5 | Args extends unknown[] = unknown[],
6 | OutType = unknown,
7 | > extends ScheduledTask {
8 | constructor(
9 | jobName: string,
10 | when: Date | number,
11 | taskFactory: TaskFactoryFn,
12 | presetArgs: Args,
13 | ) {
14 | super(jobName, when, taskFactory, presetArgs, { visible: false });
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/server/src/tasks/fixers/fixer.ts:
--------------------------------------------------------------------------------
1 | import type { Logger } from '@/util/logging/LoggerFactory.js';
2 |
3 | export default abstract class Fixer {
4 | protected abstract logger: Logger;
5 |
6 | // False if the fixed data isn't required for proper server functioning
7 | canRunInBackground: boolean = false;
8 |
9 | async run() {
10 | try {
11 | this.logger.debug('Running fixer %s', this.constructor.name);
12 | return this.runInternal();
13 | } catch (e) {
14 | this.logger.debug(
15 | e,
16 | 'Error when running fixer %s',
17 | this.constructor.name,
18 | );
19 | }
20 | }
21 |
22 | protected abstract runInternal(): Promise;
23 | }
24 |
--------------------------------------------------------------------------------
/server/src/testing/bun-test.d.ts:
--------------------------------------------------------------------------------
1 | import type { PixelFormat } from '@/ffmpeg/builder/format/PixelFormat.js';
2 | import 'vitest';
3 |
4 | interface CustomMatchers {
5 | toMatchPixelFormat: (expected: PixelFormat) => R;
6 | }
7 |
8 | declare module 'bun:test' {
9 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type
10 | interface Matchers extends CustomMatchers {}
11 | }
12 |
--------------------------------------------------------------------------------
/server/src/testing/matchers/PixelFormatMatcher.ts:
--------------------------------------------------------------------------------
1 | import type { PixelFormat } from '@/ffmpeg/builder/format/PixelFormat.js';
2 | import { expect } from 'vitest';
3 |
4 | expect.extend({
5 | toMatchPixelFormat(received: PixelFormat, expected: PixelFormat) {
6 | const { isNot } = this;
7 | return {
8 | pass: received.equals(expected),
9 | message: () =>
10 | `${received.prettyPrint()} is${
11 | isNot ? ' not' : ''
12 | } ${expected.prettyPrint()}`,
13 | };
14 | },
15 | });
16 |
--------------------------------------------------------------------------------
/server/src/testing/vitest.d.ts:
--------------------------------------------------------------------------------
1 | import type { PixelFormat } from '@/ffmpeg/builder/format/PixelFormat.js';
2 | import 'vitest';
3 |
4 | interface CustomMatchers {
5 | toMatchPixelFormat: (expected: PixelFormat) => R;
6 | }
7 |
8 | declare module 'vitest' {
9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
10 | interface Assertion extends CustomMatchers {}
11 | interface AsymmetricMatchersContaining extends CustomMatchers {}
12 | }
13 |
--------------------------------------------------------------------------------
/server/src/types/DateTimeRange.ts:
--------------------------------------------------------------------------------
1 | import type { Dayjs } from 'dayjs';
2 | import dayjs from 'dayjs';
3 | import type { Nullable } from './util.ts';
4 |
5 | export class DateTimeRange {
6 | private constructor(
7 | public from: Dayjs,
8 | public to: Dayjs,
9 | ) {}
10 |
11 | static create(
12 | from: dayjs.ConfigType | Dayjs,
13 | to: Date | Dayjs,
14 | ): Nullable {
15 | from = dayjs.isDayjs(from) ? from : dayjs(from);
16 | to = dayjs.isDayjs(to) ? to : dayjs(to);
17 |
18 | if (to.isBefore(from)) {
19 | return null;
20 | }
21 | return new DateTimeRange(from, to);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/server/src/types/OpenDateTimeRange.ts:
--------------------------------------------------------------------------------
1 | import type { Dayjs } from 'dayjs';
2 | import dayjs from 'dayjs';
3 | import { isUndefined } from 'lodash-es';
4 | import type { Nullable } from './util.ts';
5 |
6 | export class OpenDateTimeRange {
7 | private constructor(
8 | public from?: Dayjs,
9 | public to?: Dayjs,
10 | ) {}
11 |
12 | static create(
13 | from: dayjs.ConfigType | Dayjs | undefined,
14 | to: Date | Dayjs | undefined,
15 | ): Nullable {
16 | from = isUndefined(from) ? from : dayjs.isDayjs(from) ? from : dayjs(from);
17 | to = isUndefined(to) ? to : dayjs.isDayjs(to) ? to : dayjs(to);
18 |
19 | if (from && to && to.isBefore(from)) {
20 | return null;
21 | }
22 |
23 | return new OpenDateTimeRange(from, to);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/server/src/types/cron-parser.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'cron-parser/lib/expression' {
2 | namespace CronExpression {
3 | const map: ['second', 'minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek'];
4 | const constraints: [
5 | { min: 0; max: 59; chars: [] }, // Second
6 | { min: 0; max: 59; chars: [] }, // Minute
7 | { min: 0; max: 23; chars: [] }, // Hour
8 | { min: 1; max: 31; chars: ['L'] }, // Day of month
9 | { min: 1; max: 12; chars: [] }, // Month
10 | { min: 0; max: 7; chars: ['L'] }, // Day of week
11 | ];
12 | }
13 |
14 | export default CronExpression;
15 | }
16 |
--------------------------------------------------------------------------------
/server/src/types/fastify.d.ts:
--------------------------------------------------------------------------------
1 | import type { ServerContext } from '@/ServerContext.js';
2 | import type {
3 | ExtraLogLevels,
4 | LogLevels,
5 | } from '@/util/logging/LoggerFactory.js';
6 | import type { LevelWithSilent } from 'pino';
7 |
8 | declare module 'fastify' {
9 | interface FastifyRequest {
10 | serverCtx: ServerContext;
11 |
12 | // Present when the request is identified as part of
13 | // an HLS session.
14 | streamChannel?: string;
15 |
16 | disableRequestLogging?: boolean;
17 | logRequestAtLevel?: LevelWithSilent | ExtraLogLevels;
18 | }
19 |
20 | interface FastifyContextConfig {
21 | logAtLevel?: LogLevels;
22 | disableRequestLogging?: boolean;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/server/src/types/func.ts:
--------------------------------------------------------------------------------
1 | export type Func = {
2 | apply: (input: In) => Out;
3 | };
4 |
5 | export type NamedFunc = Func & { name: string };
6 |
--------------------------------------------------------------------------------
/server/src/types/internal.ts:
--------------------------------------------------------------------------------
1 | import type { Lineup } from '@/db/derived_types/Lineup.js';
2 | import type { ChannelWithRelations } from '@/db/schema/derivedTypes.js';
3 |
4 | export type ChannelAndLineup = {
5 | channel: ChannelWithRelations;
6 | lineup: Lineup;
7 | };
8 |
--------------------------------------------------------------------------------
/server/src/types/node-ssdp.d.ts:
--------------------------------------------------------------------------------
1 | // Some rudimentary types so that linters, etc don't complain.
2 | // This isn't exactly what node-ssdp supports, but eventually
3 | // we will provide our own, modern implementation.
4 | declare module 'node-ssdp' {
5 | type ServerOptions = {
6 | location: {
7 | port: number;
8 | path: string;
9 | };
10 | udn: string;
11 | allowWildcards: boolean;
12 | ssdpSig: string;
13 | customLogger?: (...args: unknown[]) => void;
14 | };
15 |
16 | class Server {
17 | constructor(options: ServerOptions);
18 | addUSN(usn: string): void;
19 | start(cb?: (...args: unknown[]) => void): Promise;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/server/src/types/schemas.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod/v4';
2 |
3 | export const TruthyQueryParam = z
4 | .union([
5 | z.boolean(),
6 | z.literal('true'),
7 | z.literal('false'),
8 | z.coerce.number(),
9 | ])
10 | .transform((value) => value === 1 || value === true || value === 'true');
11 |
--------------------------------------------------------------------------------
/server/src/util/Timer.ts:
--------------------------------------------------------------------------------
1 | import { injectable } from 'inversify';
2 | import type { ITimer } from '../interfaces/ITimer.ts';
3 | import type { LogLevels, Logger } from './logging/LoggerFactory.ts';
4 | import { timeNamedAsync, timeNamedSync } from './perf.ts';
5 |
6 | @injectable()
7 | export class Timer implements ITimer {
8 | constructor(
9 | private logger: Logger,
10 | private defaultLevel: LogLevels = 'debug',
11 | ) {}
12 |
13 | timeSync(
14 | name: string,
15 | f: () => T,
16 | opts: { level: LogLevels } = { level: this.defaultLevel },
17 | ): T {
18 | return timeNamedSync(name, this.logger, f, opts);
19 | }
20 |
21 | timeAsync(
22 | name: string,
23 | f: () => Promise,
24 | opts: { level: LogLevels } = { level: this.defaultLevel },
25 | ): Promise {
26 | return timeNamedAsync(name, this.logger, f, opts);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/server/src/util/binarySearch.test.ts:
--------------------------------------------------------------------------------
1 | import { map, range } from 'lodash-es';
2 | import { describe, test } from 'vitest';
3 | import { binarySearchRange } from './binarySearch.js';
4 |
5 | describe('binarySearch', () => {
6 | test('find in range', () => {
7 | const r = range(0, 101, 10);
8 | console.log(map(r, (r, i) => [r, i]));
9 | console.log(binarySearchRange(r, 21));
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/server/src/util/cache.ts:
--------------------------------------------------------------------------------
1 | import { isUndefined } from 'lodash-es';
2 | import type NodeCache from 'node-cache';
3 |
4 | export async function cacheGetOrSet(
5 | cache: NodeCache,
6 | key: string,
7 | cacheFill: () => Promise,
8 | setOnUndefined: boolean = false,
9 | ): Promise {
10 | let res = cache.get(key);
11 | if (isUndefined(res)) {
12 | res = await cacheFill();
13 | if (res || (isUndefined(res) && setOnUndefined)) {
14 | cache.set(key, res);
15 | }
16 | }
17 | return res;
18 | }
19 |
--------------------------------------------------------------------------------
/server/src/util/channels.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Generates a short but unique ID for this channel
3 | * in addition to the number. This helps differentiate channels
4 | * for some players whose guides can get confused.
5 | */
6 | export function getChannelId(channelNum: number): string {
7 | let num = channelNum;
8 | let id = 0;
9 | while (num !== 0) {
10 | id += (num % 10) + 48;
11 | num = Math.floor(num / 10);
12 | }
13 |
14 | return `C${channelNum}.${id}.tunarr.com`;
15 | }
16 |
--------------------------------------------------------------------------------
/server/src/util/constants.ts:
--------------------------------------------------------------------------------
1 | export const SettingsJsonFilename = 'settings.json';
2 | export const CacheFolderName = 'cache';
3 | export const SubtitlesCacheFolderName = 'subtitles';
4 | export const ImagesFolderName = 'images';
5 |
--------------------------------------------------------------------------------
/server/src/util/databaseDirectoryUtil.ts:
--------------------------------------------------------------------------------
1 | import { globalOptions } from '@/globals.js';
2 | import path from 'node:path';
3 |
4 | export function getDatabasePath(dbPath: string) {
5 | return path.join(globalOptions().databaseDirectory, dbPath);
6 | }
7 |
--------------------------------------------------------------------------------
/server/src/util/dayjs.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs';
2 | import duration from 'dayjs/plugin/duration.js';
3 | import timezone from 'dayjs/plugin/timezone.js';
4 | import utc from 'dayjs/plugin/utc.js';
5 |
6 | dayjs.extend(duration);
7 | dayjs.extend(timezone);
8 | dayjs.extend(utc);
9 |
10 | export default dayjs;
11 |
--------------------------------------------------------------------------------
/server/src/util/debug.ts:
--------------------------------------------------------------------------------
1 | import { isFunction } from 'lodash-es';
2 | import assert from 'node:assert';
3 | import { isDev } from './index.ts';
4 |
5 | export function devAssert(condition: boolean | (() => boolean)) {
6 | const res = isFunction(condition) ? condition() : condition;
7 | if (isDev) {
8 | assert(res);
9 | } else if (!res) {
10 | console.warn(new Error(), 'dev assert failed!');
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/server/src/util/enumUtil.ts:
--------------------------------------------------------------------------------
1 | import { map } from 'lodash-es';
2 |
3 | export declare type EnumLike = {
4 | [k: string]: string | number;
5 | [nu: number]: string;
6 | };
7 |
8 | export function enumKeys(
9 | obj: O,
10 | ): K[] {
11 | return Object.keys(obj).filter((k) => !Number.isNaN(k)) as K[];
12 | }
13 |
14 | export function enumValues(
15 | obj: O,
16 | ): O[K][] {
17 | return map(
18 | Object.keys(obj).filter((k) => !Number.isNaN(k)) as K[],
19 | (k) => obj[k],
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/server/src/util/inject.ts:
--------------------------------------------------------------------------------
1 | import type { interfaces } from 'inversify';
2 |
3 | export function bindFactoryFunc<
4 | Func extends (...args: Params) => Ret,
5 | Ret = ReturnType,
6 | Params extends unknown[] = Parameters,
7 | // Ret = Func extends (...args: unknown[]) => infer R ? R : never,
8 | >(
9 | bind: interfaces.Bind,
10 | key: interfaces.ServiceIdentifier,
11 | func: (ctx: interfaces.Context) => (...args: Params) => Ret,
12 | ) {
13 | return bind(key).toFactory(func);
14 | }
15 |
--------------------------------------------------------------------------------
/server/src/util/lodash-mixins.ts:
--------------------------------------------------------------------------------
1 | // import { isNil, mixin, reduce } from 'lodash-es';
2 |
3 | // function groupByTyped(
4 | // arr: T[] | undefined | null,
5 | // iterator: (t: T) => K,
6 | // ): Record {
7 | // if (isNil(arr)) {
8 | // return {} as Record;
9 | // }
10 | // return reduce(
11 | // arr,
12 | // (prev, curr) => {
13 | // const key = iterator(curr);
14 | // const last = prev[key];
15 | // return {
16 | // ...prev,
17 | // [key]: last ? [...last, curr] : [curr],
18 | // };
19 | // },
20 | // {} as Record,
21 | // );
22 | // }
23 |
24 | // mixin({ groupByTyped });
25 |
--------------------------------------------------------------------------------
/server/src/util/logging/basicPrettyTransport.ts:
--------------------------------------------------------------------------------
1 | import { once } from 'node:events';
2 | import { createWriteStream } from 'node:fs';
3 |
4 | export default async (options) => {
5 | console.log(options);
6 | const stream = createWriteStream('test.log');
7 | await once(stream, 'open');
8 | return stream;
9 | };
10 |
--------------------------------------------------------------------------------
/server/src/util/random.ts:
--------------------------------------------------------------------------------
1 | import * as randomJS from 'random-js';
2 | export const random = new randomJS.Random(
3 | randomJS.MersenneTwister19937.autoSeed(),
4 | );
5 |
--------------------------------------------------------------------------------
/server/src/util/scheduleTimeSlotsWorker.ts:
--------------------------------------------------------------------------------
1 | import { scheduleTimeSlots } from '@tunarr/shared';
2 | import type { ChannelProgram } from '@tunarr/types';
3 | import type { TimeSlotSchedule } from '@tunarr/types/api';
4 | import { parentPort, workerData } from 'node:worker_threads';
5 |
6 | const { schedule, programs } = workerData as {
7 | schedule: TimeSlotSchedule;
8 | programs: ChannelProgram[];
9 | };
10 |
11 | parentPort?.postMessage(await scheduleTimeSlots(schedule, programs));
12 |
--------------------------------------------------------------------------------
/server/src/util/schedulingUtil.test.ts:
--------------------------------------------------------------------------------
1 | import { EverySchedule } from '@tunarr/types/schemas';
2 | import dayjs from './dayjs';
3 | import { parseEveryScheduleRule } from './schedulingUtil';
4 | test('should parse every schedules', () => {
5 | const schedule: EverySchedule = {
6 | type: 'every',
7 | offsetMs: dayjs.duration(4, 'hours').asMilliseconds(),
8 | increment: 1,
9 | unit: 'hour',
10 | };
11 |
12 | expect(parseEveryScheduleRule(schedule)).toEqual('0 4-23 * * *');
13 | });
14 |
--------------------------------------------------------------------------------
/server/src/util/serverUtil.ts:
--------------------------------------------------------------------------------
1 | import type { FfmpegPlaylistQuery } from '@/api/videoApi.js';
2 | import { serverOptions } from '@/globals.js';
3 | import { isEmpty, isNil, omitBy } from 'lodash-es';
4 | import type { ParsedUrlQueryInput } from 'node:querystring';
5 | import querystring from 'node:querystring';
6 |
7 | export function makeLocalUrl(
8 | path: string,
9 | query: ParsedUrlQueryInput = {},
10 | ): string {
11 | const stringifiedQuery = querystring.stringify(omitBy(query, isNil));
12 | const urlBase = `http://localhost:${serverOptions().port}${path}`;
13 | if (!isEmpty(stringifiedQuery)) {
14 | return `${urlBase}?${stringifiedQuery}`;
15 | }
16 |
17 | return urlBase;
18 | }
19 |
20 | export function makeFfmpegPlaylistUrl(query: FfmpegPlaylistQuery) {
21 | return makeLocalUrl('/ffmpeg/playlist', query);
22 | }
23 |
--------------------------------------------------------------------------------
/server/src/util/sqliteUtil.ts:
--------------------------------------------------------------------------------
1 | export function booleanToNumber(b: boolean): number {
2 | return b ? 1 : 0;
3 | }
4 |
5 | export function numberToBoolean(n: number): boolean {
6 | return n === 0 ? false : true;
7 | }
8 |
--------------------------------------------------------------------------------
/server/src/util/streams.ts:
--------------------------------------------------------------------------------
1 | import { last } from 'lodash-es';
2 | import type { TransformCallback } from 'node:stream';
3 | import { Transform } from 'node:stream';
4 |
5 | export class NewLineTransformStream extends Transform {
6 | #buffer = '';
7 |
8 | _transform(
9 | chunk: unknown,
10 | _: BufferEncoding,
11 | callback: TransformCallback,
12 | ): void {
13 | if (chunk instanceof Buffer) {
14 | let buffer = this.#buffer + chunk.toString('utf-8');
15 | if (buffer.indexOf('\n') !== -1) {
16 | const lines = buffer.split('\n');
17 | for (const line of lines) {
18 | this.push(line.trim());
19 | }
20 | buffer = last(lines)!;
21 | }
22 |
23 | this.#buffer = buffer;
24 | }
25 |
26 | // Pass it along...
27 | callback();
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/src/util/throttle.ts:
--------------------------------------------------------------------------------
1 | //Adds a slight pause so that long operations
2 | export default function (): Promise {
3 | return new Promise((resolve) => {
4 | setTimeout(resolve, 0);
5 | });
6 | }
7 |
--------------------------------------------------------------------------------
/server/src/util/util.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from 'vitest';
2 | import { mapAsyncSeq } from './index.js';
3 |
4 | describe('utils', () => {
5 | test('mapAsyncSeq2', async () => {
6 | const result = await mapAsyncSeq([1, 2, 3, 4], async (x) => x * 2);
7 | console.log(result);
8 | expect(result).toEqual([2, 4, 6, 8]);
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/server/src/util/version.ts:
--------------------------------------------------------------------------------
1 | import tunarrPackage from '../../package.json' with { type: 'json' };
2 | import {
3 | isEdgeBuild,
4 | isNonEmptyString,
5 | isProduction,
6 | tunarrBuild,
7 | } from './index.js';
8 |
9 | let tunarrVersion: string;
10 | export const getTunarrVersion = () => {
11 | if (!tunarrVersion) {
12 | // Attempt to set for dev. This is relative to the shared package
13 | tunarrVersion = tunarrPackage.version ?? '';
14 |
15 | if (isNonEmptyString(tunarrBuild) && isEdgeBuild) {
16 | tunarrVersion += `-${tunarrBuild}`;
17 | }
18 |
19 | if (!isProduction) {
20 | tunarrVersion += '-dev';
21 | }
22 |
23 | if (tunarrVersion === '') {
24 | tunarrVersion = 'unknown';
25 | }
26 | }
27 |
28 | return tunarrVersion;
29 | };
30 |
--------------------------------------------------------------------------------
/server/tests/testServer.ts:
--------------------------------------------------------------------------------
1 | import tmp from 'tmp-promise';
2 | import { bootstrapTunarr } from '../src/bootstrap.ts';
3 | import { container } from '../src/container.ts';
4 | import { setServerOptions } from '../src/globals.js';
5 | import { Server } from '../src/Server.js';
6 |
7 | // Make this a fixture
8 | export let dbResult: tmp.DirectoryResult;
9 |
10 | export async function initTestApp(port: number) {
11 | dbResult = await tmp.dir({ unsafeCleanup: true });
12 | setServerOptions({
13 | database: dbResult.path,
14 | force_migration: false,
15 | log_level: 'debug',
16 | verbose: 0,
17 | port,
18 | admin: false,
19 | printRoutes: false,
20 | trustProxy: false,
21 | });
22 | await bootstrapTunarr();
23 |
24 | return await container.get(Server).initAndRun();
25 | }
26 |
--------------------------------------------------------------------------------
/server/tests/vitest.d.ts:
--------------------------------------------------------------------------------
1 | import { Channel } from '../src/db/entities/Channel.ts';
2 |
3 | interface CustomMatchers {
4 | toMatchChannel: (channel: Channel) => R;
5 | }
6 |
7 | declare module 'vitest' {
8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
9 | interface Assertion extends CustomMatchers {}
10 | interface AsymmetricMatchersContaining extends CustomMatchers {}
11 | }
12 |
--------------------------------------------------------------------------------
/server/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": [
4 | "./src/**/*.ts",
5 | "mikro-orm.prod.config.ts",
6 | "mikro-orm.base.config.ts"
7 | ],
8 | "exclude": [
9 | "./build/**/*",
10 | "./tests/**/*",
11 | "./src/**/*.test.ts",
12 | "./src/**/*.ignore.ts",
13 | "./src/testing/**/*.ts",
14 | "./**/streams/**/*.ts",
15 | "./scripts",
16 | ]
17 | }
--------------------------------------------------------------------------------
/server/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "extends": ["//"],
4 | "tasks": {
5 | "build": {
6 | "outputs": ["build/**"]
7 | },
8 | "typecheck": {
9 | "dependsOn": ["^build"]
10 | },
11 | "generate-db-cache": {
12 | "dependsOn": ["^bundle"],
13 | "outputs": ["temp/metadata.json"]
14 | },
15 | "bundle": {
16 | "inputs": ["./scripts/bundle-old.ts"],
17 | "dependsOn": ["^build", "^bundle"],
18 | "outputs": ["dist/**"]
19 | },
20 | "build-dev": {
21 | "dependsOn": ["^build"]
22 | },
23 | "make-bin": {
24 | "dependsOn": ["bundle"],
25 | "outputs": ["dist/bin/**"]
26 | },
27 | "lint-staged": {},
28 | "lint": {
29 | "dependsOn": ["lint-staged"]
30 | },
31 | "dev": {
32 | "persistent": true,
33 | "cache": false
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/shared/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export type GenSubtypeMapping = {
2 | [X in T['type']]: Extract;
3 | };
4 |
5 | export type GenGroupedSubtypeMapping = {
6 | [X in T['type']]: Extract[];
7 | };
8 |
9 | export type PerTypeCallback<
10 | Union extends { type: string },
11 | CallbackRet,
12 | Args extends unknown[] = [],
13 | > = {
14 | [X in Union['type']]?:
15 | | ((m: GenSubtypeMapping[X], ...rest: Args) => CallbackRet)
16 | | CallbackRet;
17 | } & {
18 | default?: ((m: Union, ...rest: Args) => CallbackRet) | CallbackRet;
19 | };
20 |
--------------------------------------------------------------------------------
/shared/src/util/constants.ts:
--------------------------------------------------------------------------------
1 | const constants = {
2 | SLACK: 9999,
3 | TVGUIDE_MAXIMUM_PADDING_LENGTH_MS: 30 * 60 * 1000,
4 | DEFAULT_GUIDE_STEALTH_DURATION: 5 * 60 * 1000,
5 | TVGUIDE_MAXIMUM_FLEX_DURATION: 6 * 60 * 60 * 1000,
6 | TOO_FREQUENT: 100,
7 | DEFAULT_DATA_DIR: '.tunarr',
8 | };
9 |
10 | export const PlexClientIdentifier = 'p86cy1w47clco3ro8t92nfy1';
11 |
12 | export const DefaultPlexHeaders = {
13 | Accept: 'application/json',
14 | 'X-Plex-Device': 'Tunarr',
15 | 'X-Plex-Device-Name': 'Tunarr',
16 | 'X-Plex-Product': 'Tunarr',
17 | 'X-Plex-Version': '0.1',
18 | 'X-Plex-Client-Identifier': PlexClientIdentifier,
19 | 'X-Plex-Platform': 'Chrome',
20 | 'X-Plex-Platform-Version': '130.0',
21 | };
22 |
23 | export default constants;
24 |
--------------------------------------------------------------------------------
/shared/src/util/debug.ts:
--------------------------------------------------------------------------------
1 | import { isFunction } from 'lodash-es';
2 |
3 | export function devAssert(condition: boolean | (() => boolean)) {
4 | const res = isFunction(condition) ? condition() : condition;
5 | if (!res) {
6 | console.warn(new Error(), 'dev assert failed!');
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/shared/src/util/env.ts:
--------------------------------------------------------------------------------
1 | // Duplicated from server - reference these instead.
2 | // export const currentEnv = once(() => {
3 | // const env = process.env['NODE_ENV'];
4 | // return env ?? 'production';
5 | // });
6 |
7 | // export const isProduction = currentEnv() === 'production';
8 | // export const isDev = currentEnv() === 'development';
9 | // export const isTest = currentEnv() === 'test';
10 | // export const isEdgeBuild = process.env['TUNARR_EDGE_BUILD'] === 'true';
11 | // export const tunarrBuild = process.env['TUNARR_BUILD'];
12 |
--------------------------------------------------------------------------------
/shared/src/util/plexUtil.ts:
--------------------------------------------------------------------------------
1 | import { ExternalId } from '@tunarr/types';
2 | import { isValidSingleExternalIdType } from '@tunarr/types/schemas';
3 | import { trimEnd } from 'lodash-es';
4 |
5 | export const parsePlexGuid = (guid: string): ExternalId | null => {
6 | if (!URL.canParse(guid)) {
7 | return null;
8 | }
9 |
10 | const parsed = new URL(guid);
11 | const idType = trimEnd(parsed.protocol, ':');
12 | if (!isValidSingleExternalIdType(idType)) {
13 | return null;
14 | }
15 |
16 | return {
17 | type: 'single',
18 | source: idType,
19 | id: parsed.hostname,
20 | };
21 | };
22 |
--------------------------------------------------------------------------------
/shared/tsconfig.prod.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": [
4 | "./src/**/*.ts"
5 | ],
6 | "exclude": [
7 | "vitest.config.ts",
8 | "./build/**/*",
9 | "./**/*.test.ts",
10 | "./**/*.ignore.ts"
11 | ],
12 | }
--------------------------------------------------------------------------------
/shared/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsup';
2 |
3 | export default defineConfig((opts) => ({
4 | entry: {
5 | index: 'src/index.ts',
6 | 'constants/index': 'src/util/constants.ts',
7 | 'util/index': 'src/util/index.ts',
8 | 'types/index': 'src/types/index.ts',
9 | },
10 | dts: !!opts.dts,
11 | splitting: false,
12 | format: 'esm',
13 | outDir: 'build',
14 | sourcemap: true,
15 | tsconfig: 'tsconfig.prod.json',
16 | }));
17 |
--------------------------------------------------------------------------------
/shared/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "extends": ["//"],
4 | "tasks": {
5 | "bundle": {
6 | "dependsOn": ["@tunarr/types#build"],
7 | "outputs": ["build/**"]
8 | },
9 | "build": {
10 | "dependsOn": ["@tunarr/types#build"],
11 | "outputs": ["build/**"]
12 | },
13 | "build-dev": {
14 | "dependsOn": ["@tunarr/types#build"],
15 | "cache": false
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/shared/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 |
3 | export default defineConfig({
4 | test: {
5 | globals: true,
6 | includeSource: ['src/**/*.test.ts'],
7 | typecheck: {
8 | tsconfig: 'tsconfig.json',
9 | },
10 | },
11 | // define: {
12 | // 'import.meta.vitest': false,
13 | // },
14 | // build: {
15 | // lib: {
16 | // formats: ['es', 'cjs'],
17 | // entry: './index.ts',
18 | // fileName: 'index',
19 | // },
20 | // },
21 | });
22 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "tasks": {
4 | "topo": {
5 | "dependsOn": ["^topo"]
6 | },
7 | "clean": {
8 | "cache": false
9 | },
10 | "build": {
11 | "dependsOn": ["^build"],
12 | "outputs": ["build/**", "dist/**"]
13 | },
14 | "bundle": {},
15 | "build-dev": {},
16 | "lint": {},
17 | "lint-fix": {},
18 | "dev": {
19 | "dependsOn": ["@tunarr/types#build", "@tunarr/shared#bundle"],
20 | "cache": false,
21 | "persistent": true
22 | },
23 | "test": {
24 | "cache": false,
25 | "persistent": true
26 | },
27 | "test:watch": {
28 | "cache": false,
29 | "persistent": true
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/types/src/CustomShow.ts:
--------------------------------------------------------------------------------
1 | import { type z } from 'zod/v4';
2 | import {
3 | type CustomShowProgrammingSchema,
4 | type CustomShowSchema,
5 | } from './schemas/customShowsSchema.js';
6 |
7 | type Alias = T & { _?: never };
8 |
9 | export type CustomShow = Alias>;
10 |
11 | export type CustomShowProgramming = Alias<
12 | z.infer
13 | >;
14 |
--------------------------------------------------------------------------------
/types/src/Event.ts:
--------------------------------------------------------------------------------
1 | import type { z } from 'zod/v4';
2 | import type {
3 | EventTypeSchema,
4 | HeartbeatEventSchema,
5 | LifecycleEventSchema,
6 | SettingsUpdateEventSchema,
7 | StreamSessionEventSchema,
8 | TunarrEventSchema,
9 | XmlTvEventSchema,
10 | } from './schemas/eventSchema.js';
11 |
12 | export type EventType = z.infer;
13 | export type XmlTvEvent = z.infer;
14 | export type SettingsUpdateEvent = z.infer;
15 | export type HeartbeatEvent = z.infer;
16 | export type LifecycleEvent = z.infer;
17 | export type TunarrEvent = z.infer;
18 | export type StreamSessionEvent = z.infer;
19 |
20 | export type EventByType = {
21 | [K in EventType]: Extract;
22 | };
23 |
--------------------------------------------------------------------------------
/types/src/FfmpegSettings.ts:
--------------------------------------------------------------------------------
1 | import type z from 'zod/v4';
2 | import { FfmpegSettingsSchema } from './schemas/settingsSchemas.js';
3 |
4 | export type FfmpegSettings = z.infer;
5 | export const defaultFfmpegSettings = FfmpegSettingsSchema.parse({});
6 |
--------------------------------------------------------------------------------
/types/src/FillerList.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod/v4';
2 | import {
3 | FillerListProgrammingSchema,
4 | FillerListSchema,
5 | } from './schemas/fillerSchema.js';
6 |
7 | type Alias = T & { _?: never };
8 |
9 | export type FillerList = Alias>;
10 |
11 | export type FillerListProgramming = Alias<
12 | z.infer
13 | >;
14 |
--------------------------------------------------------------------------------
/types/src/HdhrSettings.ts:
--------------------------------------------------------------------------------
1 | import z from 'zod/v4';
2 | import { HdhrSettingsSchema } from './schemas/settingsSchemas.js';
3 |
4 | export type HdhrSettings = z.infer;
5 |
6 | export const defaultHdhrSettings = HdhrSettingsSchema.parse({});
7 |
--------------------------------------------------------------------------------
/types/src/LanguagePreferences.ts:
--------------------------------------------------------------------------------
1 | import z from 'zod/v4';
2 | import {
3 | LanguagePreferenceSchema,
4 | LanguagePreferencesSchema,
5 | } from './schemas/settingsSchemas.js';
6 |
7 | export type LanguagePreference = z.infer;
8 | export type LanguagePreferences = z.infer;
9 |
10 | export const defaultLanguagePreferences = LanguagePreferencesSchema.parse({
11 | preferences: [{ iso6391: 'en', iso6392: 'eng', displayName: 'English' }],
12 | });
13 |
--------------------------------------------------------------------------------
/types/src/MediaSourceSettings.ts:
--------------------------------------------------------------------------------
1 | import type z from 'zod/v4';
2 | import {
3 | type EmbyServerSettingsSchema,
4 | type JellyfinServerSettingsSchema,
5 | type MediaSourceSettingsSchema,
6 | type PlexServerSettingsSchema,
7 | PlexStreamSettingsSchema,
8 | } from './schemas/settingsSchemas.js';
9 |
10 | export type PlexServerSettings = z.infer;
11 |
12 | export type JellyfinServerSettings = z.infer<
13 | typeof JellyfinServerSettingsSchema
14 | >;
15 |
16 | export type EmbyServerSettings = z.infer;
17 |
18 | export type MediaSourceSettings = z.infer;
19 |
20 | export type MediaSourceType = MediaSourceSettings['type'];
21 |
22 | export type PlexStreamSettings = z.infer;
23 |
24 | export const defaultPlexStreamSettings = PlexStreamSettingsSchema.parse({});
25 |
--------------------------------------------------------------------------------
/types/src/Subtitles.ts:
--------------------------------------------------------------------------------
1 | import type { z } from 'zod/v4';
2 | import type {
3 | SubtitleFilterSchema,
4 | SubtitlePreference,
5 | } from './schemas/subtitleSchema.js';
6 |
7 | export type SubtitlePreference = z.infer;
8 |
9 | export type SubtitleFilter = z.infer;
10 |
--------------------------------------------------------------------------------
/types/src/Tasks.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod/v4';
2 | import { TaskSchema } from './schemas/tasksSchema.js';
3 |
4 | type Alias = T & { _?: never };
5 |
6 | export type Task = Alias>;
7 |
--------------------------------------------------------------------------------
/types/src/Theme.ts:
--------------------------------------------------------------------------------
1 | import z from 'zod/v4';
2 | import { ThemeSchema } from './schemas/themeSchema.js';
3 |
4 | export const defaultTheme = ThemeSchema.parse({});
5 | export type Theme = z.infer;
6 |
--------------------------------------------------------------------------------
/types/src/TranscodeConfig.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod/v4';
2 | import {
3 | SupportedTranscodeVideoOutputFormats,
4 | TranscodeConfigSchema,
5 | } from './schemas/transcodeConfigSchemas.js';
6 | import { TupleToUnion } from './util.js';
7 |
8 | export type SupportedTranscodeVideoOutputFormat = TupleToUnion<
9 | typeof SupportedTranscodeVideoOutputFormats
10 | >;
11 |
12 | export type TranscodeConfig = z.infer;
13 |
--------------------------------------------------------------------------------
/types/src/XmlTvSettings.ts:
--------------------------------------------------------------------------------
1 | import z from 'zod/v4';
2 | import { XmlTvSettingsSchema } from './schemas/settingsSchemas.js';
3 |
4 | export type XmlTvSettings = z.infer;
5 |
6 | export const defaultXmlTvSettings = XmlTvSettingsSchema.parse({});
7 |
--------------------------------------------------------------------------------
/types/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Channel.js';
2 | export * from './CustomShow.js';
3 | export * from './Event.js';
4 | export * from './FfmpegSettings.js';
5 | export * from './FillerList.js';
6 | export * from './GuideApi.js';
7 | export * from './HdhrSettings.js';
8 | export * from './LanguagePreferences.js';
9 | export * from './MediaSourceSettings.js';
10 | export * from './misc.js';
11 | export * from './Program.js';
12 | export * from './Subtitles.js';
13 | export * from './SystemSettings.js';
14 | export * from './Tasks.js';
15 | export * from './Theme.js';
16 | export * from './TranscodeConfig.js';
17 | export * from './util.js';
18 | export * from './XmlTvSettings.js';
19 |
--------------------------------------------------------------------------------
/types/src/misc.ts:
--------------------------------------------------------------------------------
1 | import type z from 'zod/v4';
2 | import type { ExternalId } from './Program.js';
3 | import type {
4 | HealthCheckSchema,
5 | ResolutionSchema,
6 | } from './schemas/miscSchemas.js';
7 | import type {
8 | MultiExternalIdSchema,
9 | SingleExternalIdSchema,
10 | } from './schemas/utilSchemas.js';
11 |
12 | export type Resolution = z.infer;
13 |
14 | export function externalIdEquals(a: ExternalId, b: ExternalId): boolean {
15 | if (a.type !== b.type) {
16 | return false;
17 | }
18 |
19 | if (a.type === 'multi') {
20 | return a.id === b.id && a.source === b.source && a.sourceId === b.sourceId;
21 | }
22 |
23 | return a.id === b.id && a.source === b.source;
24 | }
25 |
26 | export type SingleExternalId = z.infer;
27 |
28 | export type MultiExternalId = z.infer;
29 |
30 | export type HealthCheck = z.infer;
31 |
--------------------------------------------------------------------------------
/types/src/schemas/customShowsSchema.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod/v4';
2 | import {
3 | ContentProgramSchema,
4 | CustomProgramSchema,
5 | } from './programmingSchema.js';
6 |
7 | export const CustomShowProgrammingSchema = z.array(
8 | z.discriminatedUnion('type', [ContentProgramSchema, CustomProgramSchema]),
9 | );
10 |
11 | export const CustomShowSchema = z.object({
12 | id: z.string(),
13 | name: z.string(),
14 | contentCount: z.number(),
15 | programs: z.array(CustomProgramSchema).optional(),
16 | totalDuration: z.number().nonnegative(),
17 | });
18 |
--------------------------------------------------------------------------------
/types/src/schemas/fillerSchema.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod/v4';
2 | import {
3 | ContentProgramSchema,
4 | CustomProgramSchema,
5 | } from './programmingSchema.js';
6 |
7 | export const FillerListProgrammingSchema = z.array(
8 | z.discriminatedUnion('type', [ContentProgramSchema, CustomProgramSchema]),
9 | );
10 |
11 | export const FillerListSchema = z.object({
12 | id: z.string(),
13 | name: z.string(),
14 | contentCount: z.number(),
15 | programs: FillerListProgrammingSchema.optional(),
16 | });
17 |
--------------------------------------------------------------------------------
/types/src/schemas/index.ts:
--------------------------------------------------------------------------------
1 | // This must appear first, since other modules might depend on it.
2 | // Beacuse we schemas as const, initialization order matters.
3 | export * from './utilSchemas.js';
4 |
5 | export * from './channelSchema.js';
6 | export * from './customShowsSchema.js';
7 | export * from './eventSchema.js';
8 | export * from './fillerSchema.js';
9 | export * from './guideApiSchemas.js';
10 | export * from './miscSchemas.js';
11 | export * from './programmingSchema.js';
12 | export * from './settingsSchemas.js';
13 | export * from './subtitleSchema.js';
14 | export * from './tasksSchema.js';
15 | export * from './transcodeConfigSchemas.js';
16 |
--------------------------------------------------------------------------------
/types/src/schemas/miscSchemas.ts:
--------------------------------------------------------------------------------
1 | import z from 'zod/v4';
2 |
3 | export const ResolutionSchema = z.object({
4 | widthPx: z.number(),
5 | heightPx: z.number(),
6 | });
7 |
8 | export const HealthCheckSchema = z.union([
9 | z.object({ type: z.literal('healthy') }),
10 | z.object({
11 | type: z.enum(['info', 'warning', 'error']),
12 | context: z.string(),
13 | }),
14 | ]);
15 |
--------------------------------------------------------------------------------
/types/src/schemas/subtitleSchema.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod/v4';
2 |
3 | export const SubtitleFilterSchema = z.enum([
4 | 'none',
5 | 'forced',
6 | 'default',
7 | 'any',
8 | ]);
9 |
10 | export const SubtitlePreference = z.object({
11 | langugeCode: z.string(),
12 | priority: z.number().nonnegative(),
13 | allowImageBased: z.boolean(),
14 | allowExternal: z.boolean(),
15 | filter: SubtitleFilterSchema.default('any'),
16 | });
17 |
--------------------------------------------------------------------------------
/types/src/schemas/tasksSchema.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod/v4';
2 |
3 | export const TaskSchema = z.object({
4 | id: z.string(),
5 | name: z.string(),
6 | running: z.boolean(),
7 | lastExecution: z.string().optional(),
8 | lastExecutionEpoch: z.number().optional(),
9 | nextExecution: z.string().optional(),
10 | nextExecutionEpoch: z.number().optional(),
11 | });
12 |
--------------------------------------------------------------------------------
/types/src/schemas/themeSchema.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod/v4';
2 |
3 | export const ThemeSchema = z.object({
4 | darkMode: z.boolean().optional(),
5 | pathway: z.string().default(''),
6 | });
7 |
--------------------------------------------------------------------------------
/types/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsup';
2 |
3 | export default defineConfig((opts) => ({
4 | entry: {
5 | index: 'src/index.ts',
6 | 'schemas/index': 'src/schemas/index.ts',
7 | 'plex/index': 'src/plex/index.ts',
8 | 'api/index': 'src/api/index.ts',
9 | 'jellyfin/index': 'src/jellyfin/index.ts',
10 | 'emby/index': 'src/emby/index.ts',
11 | },
12 | format: 'esm',
13 | dts: !!opts.dts,
14 | outDir: 'build',
15 | splitting: false,
16 | sourcemap: false,
17 | target: 'esnext',
18 | }));
19 |
--------------------------------------------------------------------------------
/web/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/web/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Tunarr
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/web/public/tunarr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/web/public/tunarr.png
--------------------------------------------------------------------------------
/web/src/App.css:
--------------------------------------------------------------------------------
1 | @-moz-keyframes spin {
2 | 100% {
3 | -moz-transform: rotate(360deg);
4 | }
5 | }
6 | @-webkit-keyframes spin {
7 | 100% {
8 | -webkit-transform: rotate(360deg);
9 | }
10 | }
11 | @keyframes spin {
12 | 100% {
13 | -webkit-transform: rotate(360deg);
14 | transform: rotate(360deg);
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/web/src/assets/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ../../../CHANGELOG.md
--------------------------------------------------------------------------------
/web/src/assets/emby.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/web/src/assets/emby.png
--------------------------------------------------------------------------------
/web/src/assets/emby.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/src/assets/error_this_is_fine.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/web/src/assets/error_this_is_fine.png
--------------------------------------------------------------------------------
/web/src/assets/error_this_is_fire.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/web/src/assets/error_this_is_fire.png
--------------------------------------------------------------------------------
/web/src/assets/icon_clyde_black_RGB.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/src/assets/jellyfin.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/src/assets/plex.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/src/components/TabPanel.tsx:
--------------------------------------------------------------------------------
1 | interface TabPanelProps {
2 | children?: React.ReactNode;
3 | index: number;
4 | value: number;
5 | }
6 |
7 | export function TabPanel(props: TabPanelProps) {
8 | const { children, value, index, ...other } = props;
9 |
10 | return (
11 |
18 | {/* {value === index && {children}} */}
19 | {value === index && children}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/web/src/components/base/ElevatedTooltip.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip, TooltipProps, styled, tooltipClasses } from '@mui/material';
2 |
3 | type ElevatedTooltipProps = TooltipProps & {
4 | elevation: number;
5 | };
6 | export const ElevatedTooltip = styled(
7 | ({ className, ...props }: ElevatedTooltipProps) => (
8 |
9 | ),
10 | {
11 | shouldForwardProp: (prop) => prop !== 'elevation',
12 | },
13 | )(({ theme, elevation }) => ({
14 | [`& .${tooltipClasses.tooltip}`]: {
15 | // backgroundColor: theme.palette.common.white,
16 | // color: 'rgba(0, 0, 0, 0.87)',
17 | boxShadow: theme.shadows[elevation],
18 | // fontSize: 11,
19 | margin: '8px 16px',
20 | },
21 | }));
22 |
--------------------------------------------------------------------------------
/web/src/components/base/LoadingIcon.tsx:
--------------------------------------------------------------------------------
1 | import Loop from '@mui/icons-material/Loop';
2 | import { styled } from '@mui/material/styles';
3 |
4 | export const RotatingLoopIcon = styled(Loop)({
5 | animation: 'spin 2s linear infinite',
6 | });
7 |
--------------------------------------------------------------------------------
/web/src/components/base/PaddedPaper.tsx:
--------------------------------------------------------------------------------
1 | import Paper, { PaperProps } from '@mui/material/Paper';
2 | import { styled } from '@mui/material/styles';
3 |
4 | const PaddedPaper = styled(Paper, {
5 | shouldForwardProp: () => true,
6 | })(() => ({
7 | padding: 16,
8 | }));
9 |
10 | export default PaddedPaper;
11 |
--------------------------------------------------------------------------------
/web/src/components/server_events/ServerEventsContext.ts:
--------------------------------------------------------------------------------
1 | import { Tag, TunarrEvent, tag } from '@tunarr/types';
2 | import { createContext } from 'react';
3 |
4 | export type ServerEventListenerKey = Tag;
5 |
6 | export type ServerEventListener = (event: TunarrEvent) => void;
7 |
8 | export type ServerEventsContext = {
9 | addListener: (cb: (event: TunarrEvent) => void) => ServerEventListenerKey;
10 | removeListener: (key: ServerEventListenerKey) => void;
11 | };
12 |
13 | export const ServerEventsContext = createContext({
14 | addListener: () => tag(''),
15 | removeListener() {},
16 | });
17 |
--------------------------------------------------------------------------------
/web/src/components/settings/AddPlexServer.tsx:
--------------------------------------------------------------------------------
1 | import { usePlexLogin } from '@/hooks/plex/usePlexLogin.tsx';
2 | import { AddCircle, SvgIconComponent } from '@mui/icons-material';
3 | import { Button } from '@mui/material';
4 |
5 | type AddPlexServer = {
6 | title?: string;
7 | variant?: 'text' | 'contained' | 'outlined' | undefined;
8 | icon?: SvgIconComponent;
9 | };
10 |
11 | export default function AddPlexServer({
12 | title = 'Add',
13 | variant = 'contained',
14 | ...restProps
15 | }: AddPlexServer) {
16 | const IconComponent = restProps.icon ?? AddCircle;
17 | const addPlexServer = usePlexLogin();
18 |
19 | return (
20 | }
25 | {...restProps}
26 | >
27 | {title}
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/web/src/components/slot_scheduler/ClearSlotsButton.tsx:
--------------------------------------------------------------------------------
1 | import { ClearAll } from '@mui/icons-material';
2 | import { Button } from '@mui/material';
3 |
4 | type Props = {
5 | fields: { id: string }[];
6 | remove: () => void;
7 | };
8 |
9 | export const ClearSlotsButton = ({ fields, remove }: Props) => {
10 | return (
11 | fields.length > 0 && (
12 |
15 | )
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/web/src/components/slot_scheduler/RandomSlotFormProvider.tsx:
--------------------------------------------------------------------------------
1 | import type { RandomSlotForm } from '@/pages/channels/RandomSlotEditorPage.tsx';
2 | import React from 'react';
3 | import type { UseFieldArrayReturn, UseFormReturn } from 'react-hook-form';
4 |
5 | export type RandomSlotFormContextType = UseFormReturn & {
6 | slotArray: UseFieldArrayReturn;
7 | };
8 |
9 | export const RandomSlotFormContext =
10 | React.createContext(null);
11 |
12 | export const RandomSlotFormProvider = (
13 | props: RandomSlotFormContextType & {
14 | children: React.ReactNode | React.ReactNode[];
15 | },
16 | ) => {
17 | const { children, ...rest } = props;
18 | return (
19 |
20 | {children}
21 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/web/src/components/slot_scheduler/TimeSlotFormProvider.tsx:
--------------------------------------------------------------------------------
1 | import type { TimeSlotForm } from '@/pages/channels/TimeSlotEditorPage.tsx';
2 | import React from 'react';
3 | import type { UseFieldArrayReturn, UseFormReturn } from 'react-hook-form';
4 |
5 | export type TimeSlotFormContextType = UseFormReturn & {
6 | slotArray: UseFieldArrayReturn;
7 | };
8 |
9 | export const TimeSlotFormContext =
10 | React.createContext(null);
11 |
12 | export const TimeSlotFormProvider = (
13 | props: TimeSlotFormContextType & {
14 | children: React.ReactNode | React.ReactNode[];
15 | },
16 | ) => {
17 | const { children, ...rest } = props;
18 | return (
19 |
20 | {children}
21 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/web/src/context/ProgrammingSelectionContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from 'react';
2 | import { type AddedMedia } from '../types/index.ts';
3 | import { type Nullable } from '../types/util.ts';
4 |
5 | type EntityType = 'channel' | 'filler-list' | 'custom-show';
6 |
7 | export type ProgrammingSelectionContextType = {
8 | onAddSelectedMedia: (programs: AddedMedia[]) => void;
9 | onAddMediaSuccess: () => void;
10 | entityType: EntityType;
11 | initialMediaSourceId?: string;
12 | initialLibraryId?: string;
13 | };
14 |
15 | export const ProgrammingSelectionContext =
16 | createContext>(null);
17 |
--------------------------------------------------------------------------------
/web/src/external/ffmpegApi.ts:
--------------------------------------------------------------------------------
1 | import { FfmpegInfoResponse } from '@tunarr/types/api';
2 | import { makeEndpoint } from '@tunarr/zodios-core';
3 |
4 | export const getFfmpegInfoEndpoint = makeEndpoint({
5 | method: 'get',
6 | path: '/api/ffmpeg-info',
7 | response: FfmpegInfoResponse,
8 | alias: 'getFfmpegInfo',
9 | });
10 |
--------------------------------------------------------------------------------
/web/src/generated/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/web/src/generated/.gitkeep
--------------------------------------------------------------------------------
/web/src/helpers/AsyncInterval.ts:
--------------------------------------------------------------------------------
1 | import { delay } from '../hooks/useAsyncInterval';
2 |
3 | export class AsyncInterval {
4 | #fn: (interval: AsyncInterval) => Promise;
5 | #delayMs: number | null;
6 | #running = false;
7 |
8 | constructor(
9 | fn: (interval: AsyncInterval) => Promise,
10 | delayMs: number | null,
11 | ) {
12 | this.#fn = fn;
13 | this.#delayMs = delayMs;
14 | }
15 |
16 | start() {
17 | if (this.#running || !this.#delayMs) return;
18 | this.#running = true;
19 | void this.cycle();
20 | }
21 |
22 | stop() {
23 | if (this.#running) this.#running = false;
24 | }
25 |
26 | async cycle() {
27 | await this.#fn(this);
28 | await delay(this.#delayMs!);
29 | void this.cycle();
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/web/src/helpers/DropdownOption.d.ts:
--------------------------------------------------------------------------------
1 | export type DropdownOption = {
2 | value: T;
3 | description: string;
4 | };
5 |
--------------------------------------------------------------------------------
/web/src/helpers/formatters.ts:
--------------------------------------------------------------------------------
1 | import { ChannelProgram } from '@tunarr/types';
2 | import { match } from 'ts-pattern';
3 |
4 | export function programTitle(program: ChannelProgram): string {
5 | return match(program)
6 | .with({ type: 'content', subtype: 'movie' }, (p) => p.title)
7 | .with({ type: 'content' }, (p) => p.grandparent?.title ?? p.title)
8 | .with({ type: 'custom' }, (p) => p.program?.title ?? 'Custom Program')
9 | .with({ type: 'redirect' }, (p) => `Redirect to Channel ${p.channel}`)
10 | .with({ type: 'flex' }, () => 'Flex')
11 | .exhaustive();
12 | }
13 |
--------------------------------------------------------------------------------
/web/src/helpers/language.ts:
--------------------------------------------------------------------------------
1 | import languages from '@cospired/i18n-iso-languages/index';
2 | import { seq } from '@tunarr/shared/util';
3 | import { entries, sortBy } from 'lodash-es';
4 |
5 | export const languageOptions = sortBy(
6 | seq.collect(entries(languages.getNames('en')), ([iso6391, name]) => {
7 | const iso6392 = languages.alpha2ToAlpha3B(iso6391);
8 | if (!iso6392) {
9 | return;
10 | }
11 | return {
12 | iso6391,
13 | iso6392,
14 | label: name,
15 | };
16 | }),
17 | (opt) => opt.label,
18 | );
19 |
20 | // TODO localize
21 | export const languageBy3LetterCode = (function () {
22 | const lang: Record = {};
23 | for (const { iso6392, label } of languageOptions) {
24 | lang[iso6392] = label;
25 | }
26 | return lang;
27 | })();
28 |
--------------------------------------------------------------------------------
/web/src/helpers/random.ts:
--------------------------------------------------------------------------------
1 | import * as randomJS from 'random-js';
2 |
3 |
4 | export const random = new randomJS.Random(
5 | randomJS.MersenneTwister19937.autoSeed(),
6 | );
7 |
8 |
--------------------------------------------------------------------------------
/web/src/hooks/emby/useEmbyLogin.ts:
--------------------------------------------------------------------------------
1 | import { type ApiClient } from '@/external/api.ts';
2 | import { isNonEmptyString } from '@/helpers/util.ts';
3 | import { useApiQuery } from '../useApiQuery.ts';
4 |
5 | type Opts = {
6 | uri: string;
7 | username: string;
8 | password: string;
9 | };
10 |
11 | export const embyLogin = (apiClient: ApiClient, opts: Opts) => {
12 | return apiClient.embyUserLogin({
13 | ...opts,
14 | url: opts.uri,
15 | });
16 | };
17 |
18 | export const useEmbyLogin = (opts: Opts, enabled: boolean = true) => {
19 | return useApiQuery({
20 | queryKey: ['emby', 'login', opts],
21 | queryFn(apiClient) {
22 | return embyLogin(apiClient, opts);
23 | },
24 | enabled:
25 | enabled &&
26 | isNonEmptyString(opts.uri) &&
27 | isNonEmptyString(opts.username) &&
28 | isNonEmptyString(opts.password),
29 | retry: false,
30 | refetchOnWindowFocus: false,
31 | });
32 | };
33 |
--------------------------------------------------------------------------------
/web/src/hooks/jellyfin/useEmbyLogin.ts:
--------------------------------------------------------------------------------
1 | import { ApiClient } from '@/external/api.ts';
2 | import { isNonEmptyString } from '@/helpers/util.ts';
3 | import { useApiQuery } from '../useApiQuery.ts';
4 |
5 | type Opts = {
6 | uri: string;
7 | username: string;
8 | password: string;
9 | };
10 |
11 | export const embyLogin = (apiClient: ApiClient, opts: Opts) => {
12 | return apiClient.embyUserLogin({
13 | ...opts,
14 | url: opts.uri,
15 | });
16 | };
17 |
18 | export const useEmbyLogin = (opts: Opts, enabled: boolean = true) => {
19 | return useApiQuery({
20 | queryKey: ['emby', 'login', opts],
21 | queryFn(apiClient) {
22 | return embyLogin(apiClient, opts);
23 | },
24 | enabled:
25 | enabled &&
26 | isNonEmptyString(opts.uri) &&
27 | isNonEmptyString(opts.username) &&
28 | isNonEmptyString(opts.password),
29 | retry: false,
30 | refetchOnWindowFocus: false,
31 | });
32 | };
33 |
--------------------------------------------------------------------------------
/web/src/hooks/plex/usePlexTags.ts:
--------------------------------------------------------------------------------
1 | import { useCurrentPlexMediaSourceAndLibraryView } from '@/store/programmingSelector/selectors.ts';
2 | import { useQuery } from '@tanstack/react-query';
3 | import { tag } from '@tunarr/types';
4 | import { PlexTagResult } from '@tunarr/types/plex';
5 | import { useTunarrApi } from '../useTunarrApi.ts';
6 | import { plexQueryOptions } from './plexHookUtil.ts';
7 |
8 | export const usePlexTags = (key: string) => {
9 | const apiClient = useTunarrApi();
10 | const [selectedServer, selectedLibrary] =
11 | useCurrentPlexMediaSourceAndLibraryView();
12 | const path = selectedLibrary
13 | ? `/library/sections/${selectedLibrary.library.key}/${key}`
14 | : '';
15 |
16 | return useQuery({
17 | ...plexQueryOptions(apiClient, selectedServer?.id ?? tag(''), path),
18 | });
19 | };
20 |
--------------------------------------------------------------------------------
/web/src/hooks/programming_controls/useRandomSort.ts:
--------------------------------------------------------------------------------
1 | import { shuffle } from 'lodash-es';
2 | import { setCurrentLineup } from '../../store/channelEditor/actions.ts';
3 | import { setCurrentCustomShowProgramming } from '../../store/customShowEditor/actions.ts';
4 | import {
5 | useChannelEditorLazy,
6 | useCustomShowEditor,
7 | } from '../../store/selectors.ts';
8 |
9 | export function useRandomSort() {
10 | const { materializeNewProgramList } = useChannelEditorLazy();
11 |
12 | return () => {
13 | setCurrentLineup(shuffle(materializeNewProgramList()), true);
14 | };
15 | }
16 |
17 | export function useCustomShowRandomSort() {
18 | const { programList } = useCustomShowEditor();
19 | return () => {
20 | setCurrentCustomShowProgramming(shuffle(programList));
21 | };
22 | }
23 |
--------------------------------------------------------------------------------
/web/src/hooks/programming_controls/useRemoveAllProgramming.ts:
--------------------------------------------------------------------------------
1 | import { setCurrentLineup } from '../../store/channelEditor/actions.ts';
2 |
3 | export const useRemoveAllProgramming = () => {
4 | return () => {
5 | setCurrentLineup([], true);
6 | };
7 | };
8 |
--------------------------------------------------------------------------------
/web/src/hooks/programming_controls/useRemoveFlex.ts:
--------------------------------------------------------------------------------
1 | import { some } from 'lodash-es';
2 | import useStore from '../../store/index.ts';
3 | import { materializedProgramListSelector } from '../../store/selectors.ts';
4 | import { useRemoveProgramming } from './useRemoveProgramming.ts';
5 |
6 | export function useRemoveFlex() {
7 | const programs = useStore(materializedProgramListSelector);
8 | const removeProgramming = useRemoveProgramming();
9 |
10 | return function () {
11 | const hasFlex = some(programs, { type: 'flex' });
12 | if (hasFlex) {
13 | removeProgramming({ flex: true });
14 | }
15 | };
16 | }
17 |
--------------------------------------------------------------------------------
/web/src/hooks/programming_controls/useRemoveSpecials.ts:
--------------------------------------------------------------------------------
1 | import useStore from '../../store/index.ts';
2 | import { materializedProgramListSelector } from '../../store/selectors.ts';
3 | import { useRemoveProgramming } from './useRemoveProgramming.ts';
4 |
5 | export const useRemoveSpecials = () => {
6 | const programs = useStore(materializedProgramListSelector);
7 | const removeProgramming = useRemoveProgramming();
8 |
9 | return () => {
10 | if (programs.length > 0) {
11 | removeProgramming({ specials: true });
12 | }
13 | };
14 | };
15 |
--------------------------------------------------------------------------------
/web/src/hooks/slot_scheduler/useCalculatorProgramFrequency.ts:
--------------------------------------------------------------------------------
1 | import { useChannelEditorLazy } from '@/store/selectors.ts';
2 | import { groupBy, mapValues, round } from 'lodash-es';
3 | import { useCallback } from 'react';
4 | import { getProgramGroupingKey } from '../../helpers/programUtil.ts';
5 |
6 | export const useCalculateProgramFrequency = () => {
7 | const { materializeNewProgramList } = useChannelEditorLazy();
8 |
9 | const calculateProgramFrequency = useCallback(() => {
10 | const lineup = materializeNewProgramList();
11 | const total = lineup.length;
12 | return mapValues(groupBy(lineup, getProgramGroupingKey), (group) =>
13 | round((group.length / total) * 100, 2),
14 | );
15 | }, [materializeNewProgramList]);
16 |
17 | return calculateProgramFrequency;
18 | };
19 |
--------------------------------------------------------------------------------
/web/src/hooks/useBrowserInfo.ts:
--------------------------------------------------------------------------------
1 | import Bowser from 'bowser';
2 |
3 | const browser = Bowser.getParser(window.navigator.userAgent);
4 |
5 | export const useBrowserInfo = () => {
6 | return browser;
7 | };
8 |
--------------------------------------------------------------------------------
/web/src/hooks/useChannelFormContext.ts:
--------------------------------------------------------------------------------
1 | import type { SaveableChannel } from '@tunarr/types';
2 | import { useFormContext } from 'react-hook-form';
3 |
4 | export const useChannelFormContext = () => useFormContext();
5 |
--------------------------------------------------------------------------------
/web/src/hooks/useDayjs.ts:
--------------------------------------------------------------------------------
1 | import { DayjsContext } from '@/providers/DayjsProvider';
2 | import { useContext } from 'react';
3 |
4 | export const useDayjs = () => {
5 | return useContext(DayjsContext).dayjs;
6 | };
7 |
--------------------------------------------------------------------------------
/web/src/hooks/useDebouncedState.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch, SetStateAction, useState } from 'react';
2 | import { DebouncedState, useDebounceValue } from 'usehooks-ts';
3 |
4 | export default function useDebouncedState(
5 | initialState: S | (() => S),
6 | delay?: number,
7 | // They don't expose a type for these options lmao
8 | opts?: Parameters[2],
9 | ): [S, S, Dispatch>, DebouncedState<(value: S) => void>] {
10 | const [s, set] = useState(initialState);
11 | const [dbValue, dbSet] = useDebounceValue(s, delay ?? 500, opts);
12 | return [s, dbValue, set, dbSet];
13 | }
14 |
--------------------------------------------------------------------------------
/web/src/hooks/useM3ULink.ts:
--------------------------------------------------------------------------------
1 | import { useBackendUrl } from '@/store/settings/selectors';
2 |
3 | export const useM3ULink = () => {
4 | const backendUrl = useBackendUrl();
5 | return `${backendUrl}/api/channels.m3u`;
6 | };
7 |
--------------------------------------------------------------------------------
/web/src/hooks/useProgrammingSelectionContext.ts:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 | import {
3 | ProgrammingSelectionContext,
4 | type ProgrammingSelectionContextType,
5 | } from '../context/ProgrammingSelectionContext.ts';
6 |
7 | export const useProgrammingSelectionContext = () =>
8 | useContext(
9 | ProgrammingSelectionContext,
10 | ) as NonNullable;
11 |
--------------------------------------------------------------------------------
/web/src/hooks/useRandomSlotFormContext.ts:
--------------------------------------------------------------------------------
1 | import type { RandomSlotFormContextType } from '@/components/slot_scheduler/RandomSlotFormProvider.tsx';
2 | import { RandomSlotFormContext } from '@/components/slot_scheduler/RandomSlotFormProvider.tsx';
3 | import { useContext } from 'react';
4 |
5 | export const useRandomSlotFormContext = () =>
6 | useContext(RandomSlotFormContext) as NonNullable;
7 |
--------------------------------------------------------------------------------
/web/src/hooks/useServerEvents.ts:
--------------------------------------------------------------------------------
1 | import { ServerEventsContext } from '@/components/server_events/ServerEventsContext';
2 | import { useSnackbar } from 'notistack';
3 | import { useContext, useEffect } from 'react';
4 |
5 | export function useServerEvents() {
6 | return useContext(ServerEventsContext);
7 | }
8 |
9 | export function useServerEventsSnackbar() {
10 | const { addListener, removeListener } = useServerEvents();
11 | const snackbar = useSnackbar();
12 |
13 | useEffect(() => {
14 | const key = addListener((ev) => {
15 | if (ev.message) {
16 | snackbar.enqueueSnackbar(ev.message, {
17 | variant: ev.level,
18 | });
19 | }
20 | });
21 |
22 | return () => removeListener(key);
23 | }, [addListener, removeListener, snackbar]);
24 | }
25 |
--------------------------------------------------------------------------------
/web/src/hooks/useSystemHealthChecks.ts:
--------------------------------------------------------------------------------
1 | import type { UseQueryOptions } from '@tanstack/react-query';
2 | import type { HealthCheck } from '@tunarr/types';
3 | import type { StrictOmit } from 'ts-essentials';
4 | import { useApiQuery } from './useApiQuery';
5 |
6 | export const useSystemHealthChecks = (
7 | opts?: StrictOmit<
8 | UseQueryOptions>,
9 | 'queryKey' | 'queryFn'
10 | >,
11 | ) => {
12 | return useApiQuery({
13 | ...opts,
14 | queryKey: ['system', 'health'],
15 | queryFn: (api) => api.getSystemHealth(),
16 | });
17 | };
18 |
--------------------------------------------------------------------------------
/web/src/hooks/useTimeSlotFormContext.ts:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 | import {
3 | TimeSlotFormContext,
4 | TimeSlotFormContextType,
5 | } from '../components/slot_scheduler/TimeSlotFormProvider';
6 |
7 | export const useTimeSlotFormContext = () =>
8 | useContext(TimeSlotFormContext) as NonNullable;
9 |
--------------------------------------------------------------------------------
/web/src/hooks/useTunarrApi.ts:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 | import { TunarrApiContext } from '../context/TunarrApiContext';
3 |
4 | export const useTunarrApi = () => {
5 | return useContext(TunarrApiContext);
6 | };
7 |
--------------------------------------------------------------------------------
/web/src/hooks/useXmlTvLink.ts:
--------------------------------------------------------------------------------
1 | import { useBackendUrl } from '@/store/settings/selectors';
2 | import { trimEnd } from 'lodash-es';
3 |
4 | export const useXmlTvLink = () => {
5 | const backendUri = useBackendUrl();
6 | return `${trimEnd(backendUri.trim(), '/')}/api/xmltv.xml`;
7 | };
8 |
--------------------------------------------------------------------------------
/web/src/index.css:
--------------------------------------------------------------------------------
1 | .tsqd-transitions-container > div {
2 | bottom: 50px !important;
3 | }
4 |
--------------------------------------------------------------------------------
/web/src/pages/channels/NewChannelPage.tsx:
--------------------------------------------------------------------------------
1 | import { EditChannelForm } from '@/components/channel_config/EditChannelForm';
2 | import { Route } from '@/routes/channels/new';
3 | import { Typography } from '@mui/material';
4 | import Breadcrumbs from '@mui/material/Breadcrumbs';
5 |
6 | export function NewChannelPage() {
7 | // The route code creates the "working channel"
8 | const workingChannel = Route.useLoaderData();
9 |
10 | return (
11 | <>
12 |
13 | {workingChannel && (
14 |
15 |
16 | New Channel
17 |
18 |
19 |
20 | )}
21 | >
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/web/src/pages/settings/GeneralSettingsPage.tsx:
--------------------------------------------------------------------------------
1 | import { GeneralSettingsForm } from '@/components/settings/general/GeneralSettingsForm.tsx';
2 | import { WebSettings } from '@/components/settings/general/WebSettings.tsx';
3 | import { Box, Divider } from '@mui/material';
4 | import Stack from '@mui/material/Stack';
5 | import { useSystemSettings } from '../../hooks/useSystemSettings.ts';
6 |
7 | export default function GeneralSettingsPage() {
8 | const systemSettings = useSystemSettings();
9 |
10 | // TODO: Handle loading and error states.
11 |
12 | return (
13 | systemSettings.data && (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | )
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/web/src/queryClient.ts:
--------------------------------------------------------------------------------
1 | import { QueryCache, QueryClient } from '@tanstack/react-query';
2 |
3 | // Shared query cache so non-hook / in-component usages share the same
4 | // underlying cache
5 | export const queryCache = new QueryCache({
6 | onError: (err) => {
7 | if (import.meta.env.DEV) {
8 | console.error('Query error', err);
9 | }
10 | },
11 | });
12 |
13 | export const queryClient = new QueryClient({ queryCache });
14 |
--------------------------------------------------------------------------------
/web/src/routes/channels/$channelId.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute, redirect } from '@tanstack/react-router';
2 |
3 | export const Route = createFileRoute('/channels/$channelId')({
4 | loader: (ctx) => {
5 | const channelId = ctx.params.channelId;
6 | throw redirect({
7 | to: '/channels/$channelId/programming',
8 | params: {
9 | channelId,
10 | },
11 | });
12 | },
13 | });
14 |
--------------------------------------------------------------------------------
/web/src/routes/channels/index.tsx:
--------------------------------------------------------------------------------
1 | import { channelsQuery } from '@/hooks/useChannels';
2 | import ChannelsPage from '@/pages/channels/ChannelsPage';
3 | import { createFileRoute } from '@tanstack/react-router';
4 |
5 | export const Route = createFileRoute('/channels/')({
6 | loader: ({ context: { queryClient, tunarrApiClientProvider } }) =>
7 | queryClient.ensureQueryData(channelsQuery(tunarrApiClientProvider())),
8 | component: ChannelsPage,
9 | });
10 |
--------------------------------------------------------------------------------
/web/src/routes/channels/test.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute } from '@tanstack/react-router';
2 |
3 | export const Route = createFileRoute('/channels/test')({
4 | component: () => Test
,
5 | });
6 |
--------------------------------------------------------------------------------
/web/src/routes/channels_/$channelId/programming/index.tsx:
--------------------------------------------------------------------------------
1 | import { preloadChannelAndProgramming } from '@/helpers/routeLoaders';
2 | import ChannelProgrammingPage from '@/pages/channels/ChannelProgrammingPage';
3 | import { createFileRoute } from '@tanstack/react-router';
4 |
5 | export const Route = createFileRoute('/channels/$channelId/programming/')({
6 | loader: preloadChannelAndProgramming,
7 | component: () => ,
8 | });
9 |
--------------------------------------------------------------------------------
/web/src/routes/channels_/$channelId/programming/slot-editor.tsx:
--------------------------------------------------------------------------------
1 | import { preloadChannelAndProgramming } from '@/helpers/routeLoaders';
2 | import RandomSlotEditorPage from '@/pages/channels/RandomSlotEditorPage';
3 | import { createFileRoute } from '@tanstack/react-router';
4 |
5 | export const Route = createFileRoute(
6 | '/channels/$channelId/programming/slot-editor',
7 | )({
8 | loader: preloadChannelAndProgramming,
9 | component: RandomSlotEditorPage,
10 | });
11 |
--------------------------------------------------------------------------------
/web/src/routes/channels_/$channelId/programming/time-slot-editor.tsx:
--------------------------------------------------------------------------------
1 | import { preloadChannelAndProgramming } from '@/helpers/routeLoaders';
2 | import TimeSlotEditorPage from '@/pages/channels/TimeSlotEditorPage';
3 | import { createFileRoute } from '@tanstack/react-router';
4 |
5 | export const Route = createFileRoute(
6 | '/channels/$channelId/programming/time-slot-editor',
7 | )({
8 | loader: preloadChannelAndProgramming,
9 | component: TimeSlotEditorPage,
10 | });
11 |
--------------------------------------------------------------------------------
/web/src/routes/channels_/$channelId/watch.tsx:
--------------------------------------------------------------------------------
1 | import { channelQuery } from '@/hooks/useChannels';
2 | import ChannelWatchPage from '@/pages/watch/ChannelWatchPage';
3 | import { createFileRoute } from '@tanstack/react-router';
4 | import { z } from 'zod/v4';
5 |
6 | const watchPageSearchSchema = z.object({
7 | noAutoPlay: z.coerce
8 | .boolean()
9 | .or(z.string().transform((s) => s === 'true'))
10 | .catch(true),
11 | });
12 |
13 | export const Route = createFileRoute('/channels/$channelId/watch')({
14 | validateSearch: (s) => watchPageSearchSchema.parse(s),
15 | loader: ({
16 | params: { channelId },
17 | context: { queryClient, tunarrApiClientProvider },
18 | }) =>
19 | queryClient.ensureQueryData(
20 | channelQuery(tunarrApiClientProvider(), channelId),
21 | ),
22 | component: ChannelWatchPage,
23 | });
24 |
--------------------------------------------------------------------------------
/web/src/routes/guide.tsx:
--------------------------------------------------------------------------------
1 | import GuidePage from '@/pages/guide/GuidePage';
2 | import { createFileRoute } from '@tanstack/react-router';
3 |
4 | export const Route = createFileRoute('/guide')({
5 | component: () => ,
6 | });
7 |
--------------------------------------------------------------------------------
/web/src/routes/library/custom-shows.tsx:
--------------------------------------------------------------------------------
1 | import { customShowsQuery } from '@/hooks/useCustomShows';
2 | import CustomShowsPage from '@/pages/library/CustomShowsPage';
3 | import { createFileRoute } from '@tanstack/react-router';
4 |
5 | export const Route = createFileRoute('/library/custom-shows')({
6 | loader: ({ context: { queryClient, tunarrApiClientProvider } }) =>
7 | queryClient.ensureQueryData(customShowsQuery(tunarrApiClientProvider())),
8 | component: CustomShowsPage,
9 | });
10 |
--------------------------------------------------------------------------------
/web/src/routes/library/custom-shows_.new.tsx:
--------------------------------------------------------------------------------
1 | import { UnsavedId } from '@/helpers/constants';
2 | import { NewCustomShowPage } from '@/pages/library/NewCustomShowPage';
3 | import useStore from '@/store';
4 | import { setCurrentCustomShow } from '@/store/customShowEditor/actions';
5 | import { createFileRoute } from '@tanstack/react-router';
6 |
7 | export const Route = createFileRoute('/library/custom-shows/new')({
8 | loader() {
9 | const customShow = {
10 | id: UnsavedId,
11 | name: '',
12 | contentCount: 0,
13 | totalDuration: 0,
14 | };
15 |
16 | const existingNewFiller =
17 | useStore.getState().customShowEditor.currentEntity;
18 | if (existingNewFiller?.id !== UnsavedId) {
19 | setCurrentCustomShow(customShow, []);
20 | }
21 |
22 | return {
23 | customShow,
24 | programming: [],
25 | };
26 | },
27 | component: NewCustomShowPage,
28 | });
29 |
--------------------------------------------------------------------------------
/web/src/routes/library/custom-shows_/$showId/edit.tsx:
--------------------------------------------------------------------------------
1 | import { preloadCustomShowAndProgramming } from '@/helpers/routeLoaders.ts';
2 | import EditCustomShowPage from '@/pages/library/EditCustomShowPage';
3 | import { createFileRoute } from '@tanstack/react-router';
4 |
5 | export const Route = createFileRoute('/library/custom-shows/$showId/edit')({
6 | loader: preloadCustomShowAndProgramming,
7 | component: EditCustomShowPage,
8 | });
9 |
--------------------------------------------------------------------------------
/web/src/routes/library/fillers.tsx:
--------------------------------------------------------------------------------
1 | import { fillerListsQuery } from '@/hooks/useFillerLists';
2 | import FillerListsPage from '@/pages/library/FillerListsPage';
3 | import { createFileRoute } from '@tanstack/react-router';
4 |
5 | export const Route = createFileRoute('/library/fillers')({
6 | loader: ({ context: { queryClient, tunarrApiClientProvider } }) =>
7 | queryClient.ensureQueryData(fillerListsQuery(tunarrApiClientProvider())),
8 | component: FillerListsPage,
9 | });
10 |
--------------------------------------------------------------------------------
/web/src/routes/library/fillers_/$fillerId/edit.tsx:
--------------------------------------------------------------------------------
1 | import { preloadFillerAndProgramming } from '@/helpers/routeLoaders.ts';
2 | import EditFillerPage from '@/pages/library/EditFillerPage';
3 | import { createFileRoute } from '@tanstack/react-router';
4 |
5 | export const Route = createFileRoute('/library/fillers/$fillerId/edit')({
6 | loader: preloadFillerAndProgramming,
7 | component: EditFillerPage,
8 | });
9 |
--------------------------------------------------------------------------------
/web/src/routes/library/index.tsx:
--------------------------------------------------------------------------------
1 | import LibraryIndexPage from '@/pages/library/LibraryIndexPage';
2 | import { createFileRoute } from '@tanstack/react-router';
3 |
4 | export const Route = createFileRoute('/library/')({
5 | component: LibraryIndexPage,
6 | });
7 |
--------------------------------------------------------------------------------
/web/src/routes/settings/ffmpeg.tsx:
--------------------------------------------------------------------------------
1 | import FfmpegSettingsPage from '@/pages/settings/FfmpegSettingsPage';
2 | import { createFileRoute } from '@tanstack/react-router';
3 |
4 | export const Route = createFileRoute('/settings/ffmpeg')({
5 | component: FfmpegSettingsPage,
6 | });
7 |
--------------------------------------------------------------------------------
/web/src/routes/settings/ffmpeg_/$configId.tsx:
--------------------------------------------------------------------------------
1 | import { transcodeConfigQueryOptions } from '@/hooks/settingsHooks';
2 | import { EditTranscodeConfigSettingsPage } from '@/pages/settings/EditTranscodeConfigSettingsPage';
3 | import { createFileRoute } from '@tanstack/react-router';
4 |
5 | export const Route = createFileRoute('/settings/ffmpeg/$configId')({
6 | loader: ({ params, context }) => {
7 | return context.queryClient.ensureQueryData(
8 | transcodeConfigQueryOptions(
9 | context.tunarrApiClientProvider(),
10 | params.configId,
11 | ),
12 | );
13 | },
14 | component: EditTranscodeConfigSettingsPage,
15 | });
16 |
--------------------------------------------------------------------------------
/web/src/routes/settings/ffmpeg_/new.tsx:
--------------------------------------------------------------------------------
1 | import { NewTranscodeConfigSettingsPage } from '@/pages/settings/NewTranscodeConfigSettingsPage';
2 | import { createFileRoute } from '@tanstack/react-router';
3 |
4 | export const Route = createFileRoute('/settings/ffmpeg/new')({
5 | component: NewTranscodeConfigSettingsPage,
6 | });
7 |
--------------------------------------------------------------------------------
/web/src/routes/settings/general.tsx:
--------------------------------------------------------------------------------
1 | import GeneralSettingsPage from '@/pages/settings/GeneralSettingsPage';
2 | import { createFileRoute } from '@tanstack/react-router';
3 |
4 | export const Route = createFileRoute('/settings/general')({
5 | loader: async ({ context }) => {
6 | await context.queryClient.ensureQueryData({
7 | queryFn() {
8 | return context.tunarrApiClientProvider().getSystemState();
9 | },
10 | queryKey: ['system', 'state'],
11 | });
12 | },
13 | component: GeneralSettingsPage,
14 | });
15 |
--------------------------------------------------------------------------------
/web/src/routes/settings/hdhr.tsx:
--------------------------------------------------------------------------------
1 | import HdhrSettingsPage from '@/pages/settings/HdhrSettingsPage';
2 | import { createFileRoute } from '@tanstack/react-router';
3 |
4 | export const Route = createFileRoute('/settings/hdhr')({
5 | component: HdhrSettingsPage,
6 | });
7 |
--------------------------------------------------------------------------------
/web/src/routes/settings/sources.tsx:
--------------------------------------------------------------------------------
1 | import { plexStreamSettingsQueryWithApi } from '@/hooks/settingsHooks';
2 | import MediaSourceSettingsPage from '@/pages/settings/MediaSourceSettingsPage';
3 | import { createFileRoute } from '@tanstack/react-router';
4 |
5 | export const Route = createFileRoute('/settings/sources')({
6 | loader: ({ context }) =>
7 | context.queryClient.ensureQueryData(
8 | plexStreamSettingsQueryWithApi(context.tunarrApiClientProvider()),
9 | ),
10 | component: MediaSourceSettingsPage,
11 | });
12 |
--------------------------------------------------------------------------------
/web/src/routes/settings/tasks.tsx:
--------------------------------------------------------------------------------
1 | import TaskSettingsPage from '@/pages/settings/TaskSettingsPage';
2 | import { createFileRoute } from '@tanstack/react-router';
3 |
4 | export const Route = createFileRoute('/settings/tasks')({
5 | component: TaskSettingsPage,
6 | });
7 |
--------------------------------------------------------------------------------
/web/src/routes/settings/xmltv.tsx:
--------------------------------------------------------------------------------
1 | import XmlTvSettingsPage from '@/pages/settings/XmlTvSettingsPage';
2 | import { createFileRoute } from '@tanstack/react-router';
3 |
4 | export const Route = createFileRoute('/settings/xmltv')({
5 | component: XmlTvSettingsPage,
6 | });
7 |
--------------------------------------------------------------------------------
/web/src/routes/system.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute, useChildMatches } from '@tanstack/react-router';
2 | import { head } from 'lodash-es';
3 | import { SystemLayout } from '../pages/system/SystemLayout.tsx';
4 |
5 | export const Route = createFileRoute('/system')({
6 | component: Wrapper,
7 | });
8 |
9 | function Wrapper() {
10 | const firstChild = useChildMatches();
11 | return (
12 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/web/src/routes/system/debug.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute } from '@tanstack/react-router';
2 | import { SystemDebugPage } from '../../pages/system/SystemDebugPage.tsx';
3 |
4 | export const Route = createFileRoute('/system/debug')({
5 | component: SystemDebugPage,
6 | });
7 |
--------------------------------------------------------------------------------
/web/src/routes/system/logs.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute } from '@tanstack/react-router';
2 | import { SystemLogsPage } from '../../pages/system/SystemLogsPage.tsx';
3 |
4 | export const Route = createFileRoute('/system/logs')({
5 | component: SystemLogsPage,
6 | });
7 |
--------------------------------------------------------------------------------
/web/src/routes/welcome.tsx:
--------------------------------------------------------------------------------
1 | import WelcomePage from '@/pages/welcome/WelcomePage';
2 | import { createFileRoute } from '@tanstack/react-router';
3 |
4 | export const Route = createFileRoute('/welcome')({
5 | component: WelcomePage,
6 | });
7 |
--------------------------------------------------------------------------------
/web/src/store/customShowEditor/store.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chrisbenincasa/tunarr/9bcef0ce58e9ece1a2bf6a585cabcb7540f96747/web/src/store/customShowEditor/store.ts
--------------------------------------------------------------------------------
/web/src/store/plexMetadata/actions.ts:
--------------------------------------------------------------------------------
1 | import { PlexFiltersResponse } from '@tunarr/types/plex';
2 | import useStore from '..';
3 |
4 | export const setPlexMetadataFilters = (
5 | serverName: string,
6 | key: string,
7 | filters: PlexFiltersResponse,
8 | ) =>
9 | useStore.setState(({ plexMetadata: { libraryFilters } }) => {
10 | if (!libraryFilters[serverName]) {
11 | libraryFilters[serverName] = {};
12 | }
13 |
14 | libraryFilters[serverName][key] = filters;
15 | });
16 |
--------------------------------------------------------------------------------
/web/src/store/plexMetadata/store.ts:
--------------------------------------------------------------------------------
1 | import { PlexFiltersResponse } from '@tunarr/types/plex';
2 | import { StateCreator } from 'zustand';
3 |
4 | type PlexServerName = string;
5 | type PlexServerKey = string; // ratingKey
6 |
7 | interface PlexMetadataStateInternal {
8 | libraryFilters: Record<
9 | PlexServerName,
10 | Record
11 | >;
12 | }
13 |
14 | const empty = (): PlexMetadataStateInternal => ({
15 | libraryFilters: {},
16 | });
17 |
18 | export type PlexMetadataState = {
19 | plexMetadata: PlexMetadataStateInternal;
20 | };
21 |
22 | export const plexMetadataInitialState = empty();
23 |
24 | export const createPlexMetadataState: StateCreator = () => {
25 | return { plexMetadata: plexMetadataInitialState };
26 | };
27 |
--------------------------------------------------------------------------------
/web/src/store/settings/selectors.ts:
--------------------------------------------------------------------------------
1 | import { isEmpty, trimEnd } from 'lodash-es';
2 | import useStore from '..';
3 |
4 | export const useSettings = () => {
5 | return useStore(({ settings }) => settings);
6 | };
7 |
8 | export const useBackendUrl = () => {
9 | const { backendUri } = useSettings();
10 | return trimEnd(
11 | (isEmpty(backendUri) ? window.location.origin : backendUri).trim(),
12 | '/',
13 | );
14 | };
15 |
16 | export const useChannelTableVisibilityModel = () =>
17 | useStore(({ settings }) => settings.ui.channelTableColumnModel);
18 |
--------------------------------------------------------------------------------
/web/src/types/MediaSource.d.ts:
--------------------------------------------------------------------------------
1 | export type ItemUuid = string;
2 | export type Plex = 'plex';
3 | export type Jellyfin = 'jellyfin';
4 | export type Emby = 'emby';
5 |
6 | export type Typed = T & { type: Type };
7 | export type TypedKey = Typed<
8 | {
9 | [Prop in K]: T;
10 | },
11 | Type
12 | >;
13 |
--------------------------------------------------------------------------------
/web/src/types/RouterContext.ts:
--------------------------------------------------------------------------------
1 | import { ApiClient } from '@/external/api';
2 | import { QueryClient } from '@tanstack/react-query';
3 |
4 | export interface RouterContext {
5 | queryClient: QueryClient;
6 | tunarrApiClientProvider: () => ApiClient;
7 | }
8 |
--------------------------------------------------------------------------------
/web/src/types/dom.ts:
--------------------------------------------------------------------------------
1 | import type { PopoverVirtualElement } from '@mui/material/Popover';
2 |
3 | export type PopoverAnchorEl =
4 | | null
5 | | Element
6 | | PopoverVirtualElement
7 | | (() => Element | PopoverVirtualElement | null);
8 |
--------------------------------------------------------------------------------
/web/src/types/env.d.ts:
--------------------------------------------------------------------------------
1 | declare const __TUNARR_VERSION__: string;
2 |
--------------------------------------------------------------------------------
/web/src/types/util.ts:
--------------------------------------------------------------------------------
1 | // Turns a key/val tuple type array into a union of the "keys"
2 | export type ExtractTypeKeys<
3 | Arr extends unknown[] = [],
4 | Acc extends unknown[] = [],
5 | > = Arr extends []
6 | ? Acc
7 | : // eslint-disable-next-line @typescript-eslint/no-explicit-any
8 | Arr extends [[infer Head, any], ...infer Tail]
9 | ? Head | ExtractTypeKeys
10 | : never;
11 |
12 | export type FindChild = Arr extends [
13 | [infer Head, infer Child],
14 | ...infer Tail,
15 | ]
16 | ? Head extends Target
17 | ? Child
18 | : FindChild
19 | : never;
20 |
21 | // TODO: Move these to shared types library
22 | export type Maybe = T | undefined;
23 | export type Nullable = T | null;
24 | export type Nilable = T | undefined | null;
25 | export type Size = {
26 | width?: number;
27 | height?: number;
28 | };
29 |
--------------------------------------------------------------------------------
/web/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/web/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "files": [],
4 | "include": [
5 | "./src/**/*.ts",
6 | "./src/**/*.tsx",
7 | ],
8 | "exclude": [
9 | "vite.config.ts",
10 | "vitest.config.ts",
11 | "./build/**/*",
12 | "./dist/**/*",
13 | "./src/**/*.test.ts",
14 | "./src/**/*.ignore.ts"
15 | ]
16 | }
--------------------------------------------------------------------------------
/web/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/web/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "extends": ["//"],
4 | "tasks": {
5 | "build-dev": {
6 | "dependsOn": ["^build"]
7 | },
8 | "clean-build": {
9 | "dependsOn": ["^build"]
10 | },
11 | "typecheck": {
12 | "dependsOn": ["^build"]
13 | },
14 | "bundle": {
15 | "dependsOn": ["^bundle"],
16 | "outputs": ["dist/**"]
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/web/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 |
3 | export default defineConfig({
4 | test: {
5 | globals: true,
6 | includeSource: ['src/**/*.test.ts'],
7 | },
8 | define: {
9 | 'import.meta.vitest': false,
10 | },
11 | build: {
12 | lib: {
13 | formats: ['es', 'cjs'],
14 | entry: './index.ts',
15 | fileName: 'index',
16 | },
17 | },
18 | });
19 |
--------------------------------------------------------------------------------