├── .devcontainer ├── .env.dev.server ├── .env.dev.web ├── devcontainer.json ├── docker-compose.yml └── setup.sh ├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ ├── feature_request.yml │ └── suggestion.yml ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── deploy-docs.yml │ ├── dev-docker-image.yml │ ├── docker-image.yml │ └── npm-publish.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── minaplay.png └── minaplay.svg ├── docker-compose.yml ├── entrypoint.sh ├── package.json ├── packages ├── docs │ ├── .gitignore │ ├── .vitepress │ │ ├── config.mts │ │ ├── en.mts │ │ ├── theme │ │ │ ├── index.css │ │ │ └── index.mts │ │ └── zh.mts │ ├── README.md │ ├── guide │ │ ├── common-rules.md │ │ ├── deploy.md │ │ ├── env.md │ │ ├── faq.md │ │ ├── getting-started.md │ │ ├── live.md │ │ ├── plugin.md │ │ ├── proxy.md │ │ ├── rss-source.md │ │ ├── rule.md │ │ └── what-is-minaplay.md │ ├── index.md │ ├── package-lock.json │ ├── package.json │ └── public │ │ ├── favicon.ico │ │ ├── homepage-dark.png │ │ ├── homepage.png │ │ ├── live-dark.png │ │ ├── live.png │ │ ├── minaplay.svg │ │ ├── new-rss-source-dark.png │ │ ├── new-rss-source.png │ │ ├── new-rule-dark.png │ │ ├── new-rule.png │ │ ├── plugin-console-dark.png │ │ └── plugin-console.png ├── server │ ├── .env.template │ ├── .prettierignore │ ├── LICENSE │ ├── README.md │ ├── nest-cli.json │ ├── package.json │ ├── pnpm-lock.yaml │ ├── src │ │ ├── app.module.ts │ │ ├── common │ │ │ ├── api.file.decorator.ts │ │ │ ├── api.pagination.result.dto.ts │ │ │ ├── api.query.dto.ts │ │ │ ├── application.exception.filter.ts │ │ │ ├── application.gateway.exception.filter.ts │ │ │ ├── application.gateway.interceptor.ts │ │ │ ├── application.logger.service.ts │ │ │ ├── application.message.ts │ │ │ ├── application.timeout.interceptor.ts │ │ │ ├── messages │ │ │ │ ├── action.ts │ │ │ │ ├── base64-image.ts │ │ │ │ ├── consumable-group.ts │ │ │ │ ├── consumed.ts │ │ │ │ ├── index.ts │ │ │ │ ├── markdown-text.ts │ │ │ │ ├── network-image.ts │ │ │ │ ├── pending.ts │ │ │ │ ├── resource-media.ts │ │ │ │ ├── resource-series.ts │ │ │ │ ├── text.ts │ │ │ │ └── timeout.ts │ │ │ ├── request.ip.decorator.ts │ │ │ ├── request.timeout.decorator.ts │ │ │ ├── request.user.decorator.ts │ │ │ └── socket-io.adapter.ts │ │ ├── constants.ts │ │ ├── enums │ │ │ ├── auth-action.enum.ts │ │ │ ├── error-code.enum.ts │ │ │ ├── file-source.enum.ts │ │ │ ├── index.ts │ │ │ ├── notification-event.enum.ts │ │ │ ├── notification-service.enum.ts │ │ │ ├── permission.enum.ts │ │ │ └── status.enum.ts │ │ ├── index.ts │ │ ├── interfaces │ │ │ ├── media.metadata.d.ts │ │ │ └── shims.extractor.d.ts │ │ ├── main.ts │ │ ├── migrations │ │ │ ├── 1705211396104-Schema.ts │ │ │ ├── 1712808897035-RemoveEnums.ts │ │ │ ├── 1714291901324-AddRuleParserMeta.ts │ │ │ └── 1744702921270-ModifyFileColumnSize.ts │ │ ├── modules │ │ │ ├── authorization │ │ │ │ ├── action-log-query.dto.ts │ │ │ │ ├── action-log.entity.ts │ │ │ │ ├── action-log.service.ts │ │ │ │ ├── authorization.controller.ts │ │ │ │ ├── authorization.guard.ts │ │ │ │ ├── authorization.module.ts │ │ │ │ ├── authorization.service.ts │ │ │ │ ├── authorization.ws.guard.ts │ │ │ │ ├── change-password.dto.ts │ │ │ │ ├── create-user.dto.ts │ │ │ │ ├── jwt.strategy.ts │ │ │ │ ├── login.dto.ts │ │ │ │ ├── permission.dto.ts │ │ │ │ ├── permission.entity.ts │ │ │ │ └── require-permissions.decorator.ts │ │ │ ├── file │ │ │ │ ├── file-query.dto.ts │ │ │ │ ├── file.controller.ts │ │ │ │ ├── file.entity.ts │ │ │ │ ├── file.module.ts │ │ │ │ └── file.service.ts │ │ │ ├── index.ts │ │ │ ├── live │ │ │ │ ├── live-audience.ws.guard.ts │ │ │ │ ├── live-chat.entity.ts │ │ │ │ ├── live-chat.service.ts │ │ │ │ ├── live-query.dto.ts │ │ │ │ ├── live-state.insterface.ts │ │ │ │ ├── live-state.ws.decorator.ts │ │ │ │ ├── live-state.ws.interceptor.ts │ │ │ │ ├── live-stream.controller.ts │ │ │ │ ├── live-stream.service.ts │ │ │ │ ├── live-voice.service.ts │ │ │ │ ├── live.controller.ts │ │ │ │ ├── live.dto.ts │ │ │ │ ├── live.entity.ts │ │ │ │ ├── live.gateway.ts │ │ │ │ ├── live.module-definition.ts │ │ │ │ ├── live.module.interface.ts │ │ │ │ ├── live.module.ts │ │ │ │ ├── live.service.ts │ │ │ │ └── room-owner-only.ws.decorator.ts │ │ │ ├── media │ │ │ │ ├── episode │ │ │ │ │ ├── episode-query.dto.ts │ │ │ │ │ ├── episode-update-query.dto.ts │ │ │ │ │ ├── episode.controller.ts │ │ │ │ │ ├── episode.dto.ts │ │ │ │ │ ├── episode.entity.subscriber.ts │ │ │ │ │ ├── episode.entity.ts │ │ │ │ │ └── episode.service.ts │ │ │ │ ├── media-file.service.ts │ │ │ │ ├── media-query.dto.ts │ │ │ │ ├── media.controller.ts │ │ │ │ ├── media.dto.ts │ │ │ │ ├── media.entity.subscriber.ts │ │ │ │ ├── media.entity.ts │ │ │ │ ├── media.module-definition.ts │ │ │ │ ├── media.module.interface.ts │ │ │ │ ├── media.module.ts │ │ │ │ ├── media.service.ts │ │ │ │ ├── series │ │ │ │ │ ├── series-query.dto.ts │ │ │ │ │ ├── series-subscribe.controller.ts │ │ │ │ │ ├── series-subscribe.dto.ts │ │ │ │ │ ├── series-subscribe.entity.ts │ │ │ │ │ ├── series-subscribe.service.ts │ │ │ │ │ ├── series-tag-query.dto.ts │ │ │ │ │ ├── series-tag.controller.ts │ │ │ │ │ ├── series-tag.dto.ts │ │ │ │ │ ├── series-tag.entity.ts │ │ │ │ │ ├── series-tag.service.ts │ │ │ │ │ ├── series.controller.ts │ │ │ │ │ ├── series.dto.ts │ │ │ │ │ ├── series.entity.ts │ │ │ │ │ └── series.service.ts │ │ │ │ └── view-history │ │ │ │ │ ├── view-history.controller.ts │ │ │ │ │ ├── view-history.dto.ts │ │ │ │ │ ├── view-history.entity.ts │ │ │ │ │ └── view-history.service.ts │ │ │ ├── notification │ │ │ │ ├── adapters │ │ │ │ │ ├── email │ │ │ │ │ │ ├── email.adapter.ts │ │ │ │ │ │ ├── email.config.ts │ │ │ │ │ │ └── templates │ │ │ │ │ │ │ ├── new-episode.handlebars │ │ │ │ │ │ │ ├── new-media.handlebars │ │ │ │ │ │ │ └── verify-code.handlebars │ │ │ │ │ ├── server-chan │ │ │ │ │ │ ├── server-chan.adapter.ts │ │ │ │ │ │ ├── server-chan.config.ts │ │ │ │ │ │ └── templates │ │ │ │ │ │ │ ├── new-episode.handlebars │ │ │ │ │ │ │ └── new-media.handlebars │ │ │ │ │ ├── telegram │ │ │ │ │ │ ├── telegram.adapter.ts │ │ │ │ │ │ ├── telegram.config.ts │ │ │ │ │ │ └── templates │ │ │ │ │ │ │ ├── new-episode.handlebars │ │ │ │ │ │ │ └── new-media.handlebars │ │ │ │ │ └── ws │ │ │ │ │ │ ├── notification.gateway.ts │ │ │ │ │ │ ├── ws.adapter.ts │ │ │ │ │ │ └── ws.config.ts │ │ │ │ ├── notification-event.interface.ts │ │ │ │ ├── notification-meta.dto.ts │ │ │ │ ├── notification-meta.entity.ts │ │ │ │ ├── notification-meta.service.ts │ │ │ │ ├── notification-service-adapter.interface.ts │ │ │ │ ├── notification.consumer.ts │ │ │ │ ├── notification.controller.ts │ │ │ │ ├── notification.dto.ts │ │ │ │ ├── notification.module-definition.ts │ │ │ │ ├── notification.module.interface.ts │ │ │ │ ├── notification.module.ts │ │ │ │ └── notification.service.ts │ │ │ ├── plugin │ │ │ │ ├── builtin │ │ │ │ │ ├── help │ │ │ │ │ │ ├── help.command.ts │ │ │ │ │ │ └── help.plugin.ts │ │ │ │ │ ├── plugin-manager │ │ │ │ │ │ ├── plugin-manager.command.ts │ │ │ │ │ │ └── plugin-manager.plugin.ts │ │ │ │ │ └── user-manager │ │ │ │ │ │ ├── default-root-user.service.ts │ │ │ │ │ │ ├── user-manager.command.ts │ │ │ │ │ │ └── user-manager.plugin.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── import-map-hooks.ts │ │ │ │ ├── plugin-command.interceptor.ts │ │ │ │ ├── plugin-control.ts │ │ │ │ ├── plugin-listener-context.ts │ │ │ │ ├── plugin-ref.ts │ │ │ │ ├── plugin.controller.ts │ │ │ │ ├── plugin.decorator.ts │ │ │ │ ├── plugin.gateway.ts │ │ │ │ ├── plugin.interface.ts │ │ │ │ ├── plugin.module.ts │ │ │ │ └── plugin.service.ts │ │ │ ├── subscribe │ │ │ │ ├── download │ │ │ │ │ ├── adapters │ │ │ │ │ │ ├── aria2.adapter.ts │ │ │ │ │ │ ├── downloader-adapters.ts │ │ │ │ │ │ └── webtorrent.adapter.ts │ │ │ │ │ ├── download-item-query.dto.ts │ │ │ │ │ ├── download-item-state.interface.ts │ │ │ │ │ ├── download-item.controller.ts │ │ │ │ │ ├── download-item.dto.ts │ │ │ │ │ ├── download-item.entity.ts │ │ │ │ │ ├── download-task.interface.ts │ │ │ │ │ ├── download.service.ts │ │ │ │ │ └── downloader-adapter.interface.ts │ │ │ │ ├── parse-log │ │ │ │ │ ├── parse-log-query.dto.ts │ │ │ │ │ ├── parse-log.entity.ts │ │ │ │ │ └── parse-log.service.ts │ │ │ │ ├── parse-source.consumer.ts │ │ │ │ ├── rule │ │ │ │ │ ├── rule-error-log.entity.ts │ │ │ │ │ ├── rule-error-log.service.ts │ │ │ │ │ ├── rule-query.dto.ts │ │ │ │ │ ├── rule.controller.ts │ │ │ │ │ ├── rule.dto.ts │ │ │ │ │ ├── rule.entity.ts │ │ │ │ │ ├── rule.interface.ts │ │ │ │ │ └── rule.service.ts │ │ │ │ ├── source │ │ │ │ │ ├── source-query.dto.ts │ │ │ │ │ ├── source.controller.ts │ │ │ │ │ ├── source.dto.ts │ │ │ │ │ ├── source.entity.ts │ │ │ │ │ └── source.service.ts │ │ │ │ ├── subscribe.module-definition.ts │ │ │ │ ├── subscribe.module.interface.ts │ │ │ │ └── subscribe.module.ts │ │ │ ├── system │ │ │ │ ├── system.controller.ts │ │ │ │ ├── system.module.ts │ │ │ │ └── system.service.ts │ │ │ └── user │ │ │ │ ├── user-query.dto.ts │ │ │ │ ├── user.controller.ts │ │ │ │ ├── user.dto.ts │ │ │ │ ├── user.entity.subscriber.ts │ │ │ │ ├── user.entity.ts │ │ │ │ ├── user.module.ts │ │ │ │ └── user.service.ts │ │ └── utils │ │ │ ├── build-exception.util.ts │ │ │ ├── build-plugin-command.ts │ │ │ ├── build-query-options.util.ts │ │ │ ├── compile-template.util.ts │ │ │ ├── create-identicon.util.ts │ │ │ ├── encrypt-password.util.ts │ │ │ ├── generate-md5.util.ts │ │ │ └── typed-event-emitter.ts │ ├── tsconfig.build.json │ └── tsconfig.json └── web │ ├── .browserslistrc │ ├── .editorconfig │ ├── .env.template │ ├── .eslintrc.js │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── index.html │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ ├── apple-touch-icon-180x180.png │ ├── favicon.ico │ ├── maskable-icon-512x512.png │ ├── pwa-192x192.png │ ├── pwa-512x512.png │ └── pwa-64x64.png │ ├── src │ ├── App.vue │ ├── api │ │ ├── enums │ │ │ ├── auth-action.enum.ts │ │ │ ├── error-code.enum.ts │ │ │ ├── file-source.enum.ts │ │ │ ├── notification-event.enum.ts │ │ │ ├── notification-service.enum.ts │ │ │ ├── permission.enum.ts │ │ │ └── status.enum.ts │ │ ├── interfaces │ │ │ ├── auth.interface.ts │ │ │ ├── common.interface.ts │ │ │ ├── file.interface.ts │ │ │ ├── live.interface.ts │ │ │ ├── media.interface.ts │ │ │ ├── message.interface.ts │ │ │ ├── notification.interface.ts │ │ │ ├── plugin.interface.ts │ │ │ ├── series.interface.ts │ │ │ ├── subscribe.interface.ts │ │ │ ├── system.interface.ts │ │ │ └── user.interface.ts │ │ └── templates │ │ │ ├── default.ts │ │ │ ├── download-all.ts │ │ │ ├── filter.ts │ │ │ ├── regexp.ts │ │ │ └── rule.d.ts │ ├── assets │ │ ├── banner-landscape.jpeg │ │ ├── banner-portrait.jpeg │ │ ├── banner.jpeg │ │ ├── blank-favicon.png │ │ ├── fonts │ │ │ ├── MapleMono-Bold.woff2 │ │ │ ├── MapleMono-Italic.woff2 │ │ │ └── MapleMono-Regular.woff2 │ │ ├── live-poster-fallback.png │ │ ├── logo.svg │ │ ├── mxplayer-pro.webp │ │ ├── mxplayer.webp │ │ ├── potplayer.webp │ │ └── vlc.svg │ ├── components │ │ ├── app │ │ │ ├── AuthedRouterView.vue │ │ │ ├── ExpandableText.vue │ │ │ ├── MessagesContainer.vue │ │ │ ├── MonacoEditor.vue │ │ │ ├── MultiItemsLoader.vue │ │ │ ├── NavSections.vue │ │ │ ├── NavTabs.vue │ │ │ ├── SingleItemLoader.vue │ │ │ ├── TimeAgo.vue │ │ │ ├── ToTopContainer.vue │ │ │ ├── UploadDrawer.vue │ │ │ ├── UploadMedia.vue │ │ │ ├── VideoPlayer.vue │ │ │ └── ZoomImg.vue │ │ ├── live │ │ │ ├── LiveChatMessage.vue │ │ │ ├── LiveMessage.vue │ │ │ ├── LiveNotifyMessage.vue │ │ │ ├── LiveOverview.vue │ │ │ └── LiveSelector.vue │ │ ├── notification │ │ │ ├── EmailBindPrompt.vue │ │ │ ├── NotificationContent.vue │ │ │ ├── NotificationWindow.vue │ │ │ ├── ServerChanBindPrompt.vue │ │ │ └── TelegramBindPrompt.vue │ │ ├── plugin │ │ │ ├── PluginChatMessage.vue │ │ │ ├── PluginConsole.vue │ │ │ ├── PluginMessageItem.vue │ │ │ ├── PluginOverview.vue │ │ │ └── PluginParser.vue │ │ ├── resource │ │ │ ├── MediaOverview.vue │ │ │ ├── MediaOverviewLandscape.vue │ │ │ ├── MediaSelector.vue │ │ │ ├── SeriesOverview.vue │ │ │ ├── SeriesSelector.vue │ │ │ └── plates │ │ │ │ ├── HistoryPlate.vue │ │ │ │ ├── MediaUpdatesPlate.vue │ │ │ │ ├── SeriesPlate.vue │ │ │ │ └── SeriesUpdatesPlate.vue │ │ ├── rule │ │ │ └── RuleOverview.vue │ │ ├── source │ │ │ ├── DownloadItemOverview.vue │ │ │ └── SourceOverview.vue │ │ └── user │ │ │ └── UserAvatar.vue │ ├── composables │ │ ├── use-async-task.ts │ │ ├── use-axios-page-loader.ts │ │ ├── use-axios-request.ts │ │ └── use-socket-io-connection.ts │ ├── css │ │ ├── highlightjs.sass │ │ └── main.sass │ ├── lang │ │ ├── en.ts │ │ ├── index.ts │ │ └── zh.ts │ ├── layouts │ │ ├── home │ │ │ ├── Layout.vue │ │ │ └── View.vue │ │ └── login │ │ │ └── View.vue │ ├── main.ts │ ├── plugins │ │ ├── index.ts │ │ ├── monaco.ts │ │ └── vuetify.ts │ ├── router │ │ └── index.ts │ ├── shims-danmu.js.d.ts │ ├── shims-vue.d.ts │ ├── store │ │ ├── api.ts │ │ ├── index.ts │ │ ├── layout.ts │ │ ├── notification.ts │ │ ├── settings.ts │ │ └── toast.ts │ ├── utils │ │ └── utils.ts │ ├── views │ │ ├── About.vue │ │ ├── Dashboard.vue │ │ ├── Live.vue │ │ ├── Parser.vue │ │ ├── Resource.vue │ │ ├── Rule.vue │ │ ├── Setting.vue │ │ ├── Source.vue │ │ ├── common │ │ │ └── SubscribeDownload.vue │ │ ├── dashboard │ │ │ ├── app │ │ │ │ ├── DashActionLogs.vue │ │ │ │ ├── DashLogs.vue │ │ │ │ ├── DashPlugins.vue │ │ │ │ └── DashSystem.vue │ │ │ └── modules │ │ │ │ ├── DashEpisodes.vue │ │ │ │ ├── DashFiles.vue │ │ │ │ ├── DashLives.vue │ │ │ │ ├── DashMedias.vue │ │ │ │ ├── DashRules.vue │ │ │ │ ├── DashSeries.vue │ │ │ │ ├── DashSources.vue │ │ │ │ └── DashUsers.vue │ │ ├── error │ │ │ ├── NoPermission.vue │ │ │ ├── NoPlates.vue │ │ │ └── NotFound.vue │ │ ├── live │ │ │ └── LivePlay.vue │ │ ├── parser │ │ │ ├── ParserHome.vue │ │ │ └── ParserSeries.vue │ │ ├── resource │ │ │ ├── MediaPlay.vue │ │ │ ├── ResourceSearch.vue │ │ │ └── SeriesInfo.vue │ │ ├── rule │ │ │ ├── RuleDetail.vue │ │ │ ├── RuleErrorLog.vue │ │ │ └── RuleInfo.vue │ │ ├── setting │ │ │ ├── SettingApp.vue │ │ │ └── SettingProfile.vue │ │ └── source │ │ │ ├── SourceDetail.vue │ │ │ ├── SourceInfo.vue │ │ │ ├── SourceParseLog.vue │ │ │ ├── SourceRawData.vue │ │ │ └── SourceRule.vue │ └── vite-env.d.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── pnpm-lock.yaml /.devcontainer/.env.dev.server: -------------------------------------------------------------------------------- 1 | # Application 2 | APP_ENV=dev 3 | APP_HOST=0.0.0.0 4 | APP_PORT=3000 5 | APP_SECRET_KEY=devkey 6 | APP_HTTP_PROXY= 7 | APP_ENABLE_CORS=1 8 | 9 | # FFmpeg 10 | FFMPEG_PATH=ffmpeg 11 | FFPROBE_PATH=ffprobe 12 | 13 | # Database 14 | DB_HOST=mysql 15 | DB_PORT=3306 16 | DB_USERNAME=root 17 | DB_PASSWORD= 18 | DB_DATABASE=minaplay 19 | DB_SSL_CA= 20 | 21 | # Redis 22 | REDIS_HOST=redis 23 | REDIS_PORT=6379 24 | REDIS_DB=0 25 | REDIS_PASSWORD= 26 | 27 | # Downloader 28 | DOWNLOAD_ADAPTER=aria2 29 | DOWNLOAD_AUTO_UPDATE_TRACKER=1 30 | DOWNLOAD_TRACKER_LIST_URL="https://cdn.jsdelivr.net/gh/ngosang/trackerslist@master/trackers_best.txt" 31 | 32 | # Aria2 33 | ARIA2_RPC_HOST=127.0.0.1 34 | ARIA2_RPC_PORT=6800 35 | ARIA2_RPC_PATH=/jsonrpc 36 | ARIA2_RPC_SECRET= 37 | 38 | # Mediasoup 39 | MS_ANNOUNCED_ADDRESS=127.0.0.1 40 | MS_RTC_MIN_PORT=12000 41 | MS_RTC_MAX_PORT=12100 42 | MS_WORKERS_NUM=4 43 | MS_AUDIO_CLOCK_RATE=48000 44 | MS_AUDIO_CHANNELS=2 45 | MS_AUDIO_MAX_INCOME_BITRATE=1500000 46 | 47 | # Live Stream 48 | STREAM_RTMP_PORT=1935 49 | STREAM_HTTP_PORT=3001 50 | STREAM_CHUNK_SIZE=60000 51 | STREAM_PUBLISH_KEY= 52 | 53 | # Notifications 54 | # WS 55 | NOTIFY_WS=1 56 | 57 | # Email 58 | NOTIFY_EMAIL=0 59 | NOTIFY_EMAIL_SMTP_HOST=mail.example.com 60 | NOTIFY_EMAIL_SMTP_PORT=25 61 | NOTIFY_EMAIL_SMTP_SECURE=0 62 | NOTIFY_EMAIL_SMTP_USER=no-reply@example.com 63 | NOTIFY_EMAIL_SMTP_PASSWORD=password 64 | NOTIFY_EMAIL_ORIGIN="MinaPlay " 65 | NOTIFY_EMAIL_SUBJECT="MinaPlay Email Notification" 66 | 67 | # ServerChan 68 | NOTIFY_SERVER_CHAN=0 69 | 70 | # Telegram 71 | NOTIFY_TELEGRAM=0 72 | -------------------------------------------------------------------------------- /.devcontainer/.env.dev.web: -------------------------------------------------------------------------------- 1 | VITE_API_HOST="http://localhost:3000" -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node 3 | { 4 | "name": "Minaplay_DevContainer", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "dockerComposeFile": "docker-compose.yml", 7 | "service": "app", 8 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", 9 | 10 | // Features to add to the dev container. More info: https://containers.dev/features. 11 | // "features": {}, 12 | 13 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 14 | "forwardPorts": [5173, 3000, 3001, 1935] 15 | 16 | // Use 'postCreateCommand' to run commands after the container is created. 17 | // "postCreateCommand": "sh ./.devcontainer/setup.sh", 18 | 19 | // Configure tool-specific properties. 20 | // "customizations": {}, 21 | 22 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 23 | // "remoteUser": "root" 24 | } 25 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | image: mcr.microsoft.com/vscode/devcontainers/typescript-node:20-bookworm 4 | container_name: basic_env 5 | environment: 6 | - DB_HOST=mysql 7 | - REDIS_HOST=redis 8 | - MS_ANNOUNCED_IP=127.0.0.1 9 | networks: 10 | - minaplay-network 11 | ports: 12 | - "5173:5173" 13 | - "3000:3000" 14 | - "3001:3001" 15 | - "1935:1935" 16 | volumes: 17 | - ../..:/workspaces:cached 18 | depends_on: 19 | - mysql 20 | - redis 21 | command: sleep infinity 22 | 23 | mysql: 24 | image: mysql:8 25 | container_name: mysql 26 | environment: 27 | - TZ=Asia/Shanghai 28 | - MYSQL_ALLOW_EMPTY_PASSWORD=yes 29 | - MYSQL_DATABASE=minaplay 30 | networks: 31 | - minaplay-network 32 | ports: 33 | - "3306:3306" 34 | volumes: 35 | - mysql-data:/var/lib/mysql 36 | 37 | redis: 38 | image: redis:latest 39 | container_name: redis 40 | networks: 41 | - minaplay-network 42 | ports: 43 | - "6379:6379" 44 | 45 | volumes: 46 | mysql-data: 47 | 48 | networks: 49 | minaplay-network: -------------------------------------------------------------------------------- /.devcontainer/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | pwd 3 | 4 | # Install packages 5 | sudo apt update 6 | sudo apt install -y ffmpeg aria2 7 | 8 | # Start aria2 9 | nohup aria2c --enable-rpc --rpc-allow-origin-all > aria2.log 2>&1 & 10 | 11 | ##################### 12 | # web project setup # 13 | ##################### 14 | # Navigate to the web directory 15 | cd ./packages/web 16 | # setup 17 | pnpm install 18 | cp ../../.devcontainer/.env.dev.web .env 19 | 20 | ######################## 21 | # server project setup # 22 | ######################## 23 | # Navigate to the server directory 24 | cd ../server 25 | # setup 26 | pnpm install 27 | cp ../../.devcontainer/.env.dev.server .env 28 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | packages/server/node_modules 2 | packages/server/dist 3 | packages/server/data 4 | packages/server/public 5 | packages/server/.env 6 | packages/server/.env.prod 7 | packages/server/.env.dev 8 | 9 | packages/web/node_modules 10 | packages/web/dist 11 | packages/web/dev-dist 12 | packages/web/.env 13 | packages/web/.env.production 14 | packages/web/.env.local 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41B Bug 反馈" 2 | description: "有些东西不太对劲 \U0001F914" 3 | title: "[Bug] 请在此处简单描述你的问题" 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: "请按照模板描述您遇到的问题" 9 | - type: checkboxes 10 | attributes: 11 | label: "现存的 issue 中存在您想提的问题吗" 12 | description: "请查看 [现存 issue](../issues?q=is%3Aissue) 中是否存在你目前遇到的问题" 13 | options: 14 | - label: "我已查阅现存 issue,并且我目前的问题不在其中" 15 | required: true 16 | - type: input 17 | attributes: 18 | label: "MinaPlay 版本" 19 | description: "遇到问题时 MinaPlay 的版本" 20 | validations: 21 | required: true 22 | - type: dropdown 23 | attributes: 24 | label: "问题定位" 25 | description: "您在哪里遇到的这个问题" 26 | options: 27 | - "server 服务端" 28 | - "web 用户界面" 29 | - "其他位置" 30 | validations: 31 | required: true 32 | - type: textarea 33 | attributes: 34 | label: "问题描述" 35 | description: "在这里描述您遇到的问题" 36 | validations: 37 | required: true 38 | - type: textarea 39 | attributes: 40 | label: "系统日志" 41 | description: "请把问题发生时的运行日志粘贴到这里(如果有)" 42 | render: text 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: "\u2049 说明文档" 5 | url: "https://nepsyn.github.io/minaplay" 6 | about: "使用说明文档" 7 | - name: "\u2753 FAQ" 8 | url: "https://nepsyn.github.io/minaplay/guide/faq.html" 9 | about: "常见问题" 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F680 功能请求" 2 | description: "我有个新点子 \U0001F63B" 3 | title: "[Feature Request] 请在此处简单描述你的新功能请求" 4 | labels: ["feature request"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: "请按照模板描述您的新功能请求" 9 | - type: checkboxes 10 | attributes: 11 | label: "现存的 issue 中存在您想提的功能请求吗" 12 | description: "请查看 [现存 issue](../issues?q=is%3Aissue) 中是否存在你目前想要的功能请求" 13 | options: 14 | - label: "我已查阅现存 issue,并且我目前的功能请求不在其中" 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: "功能描述" 19 | description: "在这里描述您希望添加的新功能" 20 | validations: 21 | required: true 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/suggestion.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F525 项目建议" 2 | description: "我想提一些建议 \U00002728" 3 | title: "[Suggestion] 请在此处简单描述你建议" 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: "请按照模板描述您的新建议" 8 | - type: textarea 9 | attributes: 10 | label: "项目建议" 11 | description: "在这里描述您的宝贵建议" 12 | validations: 13 | required: true 14 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Features 2 | 3 | ## Changes 4 | 5 | ## Fixes 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/packages/server" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | - package-ecosystem: "npm" # See documentation for possible values 13 | directory: "/packages/web" # Location of package manifests 14 | schedule: 15 | interval: "monthly" 16 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy VitePress site to Pages 2 | 3 | on: 4 | push: 5 | branches: [docs] 6 | 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | concurrency: 15 | group: pages 16 | cancel-in-progress: false 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | - name: Setup Node 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: 20 30 | cache: npm 31 | - name: Setup Pages 32 | uses: actions/configure-pages@v4 33 | - name: Install dependencies 34 | run: npm ci 35 | working-directory: packages/docs 36 | - name: Build with VitePress 37 | run: npm run docs:build 38 | working-directory: packages/docs 39 | - name: Upload artifact 40 | uses: actions/upload-pages-artifact@v3 41 | with: 42 | path: packages/docs/.vitepress/dist 43 | 44 | deploy: 45 | environment: 46 | name: github-pages 47 | url: ${{ steps.deployment.outputs.page_url }} 48 | needs: build 49 | runs-on: ubuntu-latest 50 | name: Deploy 51 | steps: 52 | - name: Deploy to GitHub Pages 53 | id: deployment 54 | uses: actions/deploy-pages@v4 55 | -------------------------------------------------------------------------------- /.github/workflows/dev-docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish dev-latest Docker image 2 | 3 | on: 4 | push: 5 | branches: [ dev ] 6 | 7 | workflow_dispatch: 8 | 9 | jobs: 10 | docker: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | - name: Set up QEMU 16 | uses: docker/setup-qemu-action@v3 17 | - name: Set up Docker Buildx 18 | uses: docker/setup-buildx-action@v3 19 | - name: Login to Docker Hub 20 | uses: docker/login-action@v3 21 | with: 22 | username: ${{ secrets.DOCKERHUB_USERNAME }} 23 | password: ${{ secrets.DOCKERHUB_TOKEN }} 24 | - name: Build and push 25 | uses: docker/build-push-action@v5 26 | with: 27 | push: true 28 | platforms: linux/amd64,linux/arm64 29 | tags: nepsyn/minaplay:dev-latest 30 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish latest Docker image 2 | 3 | on: 4 | release: 5 | types: [ created ] 6 | 7 | workflow_dispatch: 8 | 9 | jobs: 10 | docker: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | ref: master 17 | - name: Set up QEMU 18 | uses: docker/setup-qemu-action@v3 19 | - name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v3 21 | - name: Login to Docker Hub 22 | uses: docker/login-action@v3 23 | with: 24 | username: ${{ secrets.DOCKERHUB_USERNAME }} 25 | password: ${{ secrets.DOCKERHUB_TOKEN }} 26 | - name: Get package version 27 | id: package-version 28 | uses: martinbeentjes/npm-get-version-action@v1.3.1 29 | with: 30 | path: packages/server 31 | - name: Build and push 32 | uses: docker/build-push-action@v5 33 | with: 34 | push: true 35 | platforms: linux/amd64,linux/arm64 36 | tags: | 37 | nepsyn/minaplay:latest 38 | nepsyn/minaplay:${{ steps.package-version.outputs.current-version }} 39 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Publish Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | workflow_dispatch: 11 | 12 | jobs: 13 | publish-npm: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | ref: master 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | registry-url: https://registry.npmjs.org/ 23 | - run: npm install pnpm --global 24 | working-directory: packages/server 25 | - run: pnpm install 26 | working-directory: packages/server 27 | - run: pnpm run build 28 | working-directory: packages/server 29 | - run: npm publish --access public 30 | working-directory: packages/server 31 | env: 32 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 33 | -------------------------------------------------------------------------------- /.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 | data 12 | dist 13 | dev-dist 14 | *.local 15 | 16 | ormconfig.js 17 | .env 18 | .env.production 19 | .env.development 20 | 21 | # Editor directories and files 22 | .vscode/* 23 | !.vscode/extensions.json 24 | .idea 25 | .DS_Store 26 | *.suo 27 | *.ntvs* 28 | *.njsproj 29 | *.sln 30 | *.sw? 31 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 120 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ChangeLog 2 | 3 | ## v0.2.1 [2024-05-14] 4 | 5 | ### feature 6 | 7 | - [server] Add `savePath` in `RuleFileDescriptor` for custom media save path 8 | - [server] Add rule hook delegates for plugin parsers 9 | 10 | ### change 11 | 12 | - [server] Do not exit program on unhandled rejection 13 | - [server&web] Optimize notification service APIs & Add test APIs 14 | 15 | ### fix 16 | 17 | - [web] Multi-items loader did not work properly 18 | - [server&web] Minor bugs fix 19 | 20 | ## v0.2.0 [2024-04-29] 21 | 22 | ### feature 23 | 24 | - [server] Add `ServerChan` notification service 25 | - [server] Add `Telegram` notification service 26 | - [server&web] Add plugin parser for 3rd-party RSS sources 27 | 28 | ### change 29 | 30 | - [web] Change layout of left-side navigation bar 31 | 32 | ### fix 33 | 34 | - [web] Service worker is not working properly 35 | - [server&web] Minor bugs fix 36 | 37 | 38 | ## v0.1.1 [2024-04-12] 39 | 40 | ### feature 41 | 42 | - [server] Add PluginParser for sources parsing in MinaPlay plugins 43 | - [server&web] Add `MarkdownText` message type 44 | - [server] Add MinaPlay update check API 45 | - [server] Add `Network` & `Local` file sources 46 | - [server] Add version update check 47 | 48 | ### change 49 | 50 | - [server&web] Remove `ConsumableFeedback` message type 51 | - [server] Remove enum types in db models 52 | - [web] Redesign colors in dark mode 53 | 54 | ### fix 55 | 56 | - [server&web] Minor bugs fix 57 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /app 4 | 5 | ENV LANG="C.UTF-8" TZ=Asia/Shanghai 6 | 7 | COPY packages/server/. /app/ 8 | COPY packages/web/. /web/ 9 | 10 | RUN set -ex \ 11 | && apk add --no-cache bash tini openssl ffmpeg aria2 python3 py3-pip g++ make linux-headers \ 12 | && npm install pnpm --global \ 13 | && pnpm install \ 14 | && pnpm run build \ 15 | && cd /web \ 16 | && pnpm install \ 17 | && pnpm run build \ 18 | && cd /app \ 19 | && mv /web/dist public \ 20 | && pnpm store prune \ 21 | && apk del python3 py3-pip g++ make linux-headers \ 22 | && rm -rf /web /root/.cache /tmp/* 23 | 24 | COPY --chmod=755 entrypoint.sh /entrypoint.sh 25 | 26 | ENTRYPOINT ["tini", "-g", "--", "/entrypoint.sh"] 27 | 28 | EXPOSE 3000 29 | VOLUME ["/app/data"] 30 | -------------------------------------------------------------------------------- /assets/minaplay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/assets/minaplay.png -------------------------------------------------------------------------------- /assets/minaplay.svg: -------------------------------------------------------------------------------- 1 | 2 | MinaPlay 3 | 4 | 5 | Layer 1 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | minaplay-mysql: 5 | image: "mysql:8" 6 | container_name: minaplay-mysql 7 | networks: 8 | - minaplay-network 9 | environment: 10 | - TZ=Asia/Shanghai 11 | - MYSQL_ALLOW_EMPTY_PASSWORD=yes 12 | - MYSQL_DATABASE=minaplay 13 | restart: always 14 | volumes: 15 | - mysql-data:/var/lib/mysql 16 | 17 | minaplay-redis: 18 | image: "redis:latest" 19 | container_name: minaplay-redis 20 | networks: 21 | - minaplay-network 22 | restart: always 23 | 24 | minaplay: 25 | image: "nepsyn/minaplay:latest" 26 | container_name: minaplay 27 | networks: 28 | - minaplay-network 29 | volumes: 30 | - ./data:/app/data 31 | environment: 32 | - DB_HOST=minaplay-mysql 33 | - REDIS_HOST=minaplay-redis 34 | - MS_ANNOUNCED_ADDRESS=127.0.0.1 # 在需要放映室语音通话服务的情况下改为宿主机外部访问地址 35 | ports: 36 | - "3000:3000" 37 | - "12000-12100:12000-12100" 38 | - "12000-12100:12000-12100/udp" 39 | depends_on: 40 | - minaplay-mysql 41 | - minaplay-redis 42 | restart: unless-stopped 43 | 44 | volumes: 45 | mysql-data: 46 | 47 | networks: 48 | minaplay-network: 49 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # shellcheck shell=bash 3 | 4 | if [[ ! -e /app/.env ]]; then 5 | cp /app/.env.template /app/.env 6 | fi 7 | 8 | if [ -z "$APP_SECRET_KEY" ]; then 9 | export APP_SECRET_KEY=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) 10 | fi 11 | 12 | nohup aria2c --enable-rpc --rpc-allow-origin-all > aria2.log 2>&1 & 13 | exec node dist/main 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@minaplay/minaplay", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": " Nepsyn", 6 | "license": "AGPL-3.0", 7 | "devDependencies": { 8 | "prettier": "^2.8.7", 9 | "tsconfig-paths": "^4.2.0" 10 | }, 11 | "dependencies": {} 12 | } 13 | -------------------------------------------------------------------------------- /packages/docs/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | cache/ 3 | -------------------------------------------------------------------------------- /packages/docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress'; 2 | import { zh } from './zh.mjs'; 3 | 4 | // https://vitepress.dev/reference/site-config 5 | export default defineConfig({ 6 | title: 'MinaPlay', 7 | description: 'MinaPlay official document', 8 | lastUpdated: true, 9 | base: '/minaplay/', 10 | srcExclude: ['README.md'], 11 | head: [ 12 | ['script', { 13 | defer: true, 14 | src: 'https://us.umami.is/script.js', 15 | 'data-website-id': '6de8215f-84d9-4fc9-a6ca-f6850d2ebb2b', 16 | }], 17 | ], 18 | markdown: { 19 | image: { 20 | lazyLoading: true, 21 | }, 22 | }, 23 | locales: { 24 | root: { 25 | label: '简体中文', 26 | ...zh, 27 | }, 28 | // en: { 29 | // label: 'English', 30 | // ...en, 31 | // }, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /packages/docs/.vitepress/en.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress'; 2 | 3 | export const en = defineConfig({ 4 | lang: 'en-US', 5 | description: 'MinaPlay official document', 6 | themeConfig: { 7 | // https://vitepress.dev/reference/default-theme-config 8 | logo: '/minaplay.svg', 9 | editLink: { 10 | pattern: 'https://github.com/nepsyn/minaplay/edit/docs/packages/docs/:path', 11 | }, 12 | socialLinks: [{ icon: 'github', link: 'https://github.com/nepsyn/minaplay' }], 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /packages/docs/.vitepress/theme/index.css: -------------------------------------------------------------------------------- 1 | .medium-zoom-overlay { 2 | z-index: 30; 3 | } 4 | 5 | .medium-zoom-image--opened { 6 | z-index: 31; 7 | } 8 | -------------------------------------------------------------------------------- /packages/docs/.vitepress/theme/index.mts: -------------------------------------------------------------------------------- 1 | import DefaultTheme from 'vitepress/theme'; 2 | import { onMounted, watch, nextTick } from 'vue'; 3 | import { useRoute } from 'vitepress'; 4 | import mediumZoom from 'medium-zoom'; 5 | 6 | import './index.css'; 7 | 8 | export default { 9 | ...DefaultTheme, 10 | setup() { 11 | const route = useRoute(); 12 | const initZoom = () => { 13 | mediumZoom('[data-zoomable]', { background: 'var(--vp-c-bg)' }); 14 | }; 15 | onMounted(() => { 16 | initZoom(); 17 | }); 18 | watch( 19 | () => route.path, 20 | () => nextTick(() => initZoom()) 21 | ); 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /packages/docs/README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | logo 4 |
5 | title 6 | 7 | ---- 8 | 9 | MinaPlay 是一个视频聚合 RSS 订阅的自动下载管理工具 10 | 11 | MinaPlay docs 属于 [MinaPlay](../../README.md) 项目的一部分 12 | 13 |
14 | 15 | ## 项目说明 16 | 17 | MinaPlay 是一个用于追番 / 追剧的个人媒体库。MinaPlay 根据用户创建的 RSS 订阅源、订阅规则自动下载媒体文件并生成描述信息。 18 | 19 | MinaPlay docs 是 MinaPlay 使用 [VitePress](https://vitepress.dev/zh/) 构建的用户文档。 20 | 我们欢迎各种形式的贡献,如果您有比较好的想法和建议,欢迎提出 issue。 21 | 22 | ## 构建过程 23 | 24 | ### 项目依赖 25 | 26 | - [Node.js](https://nodejs.org/en) (版本 >= 18) & npm 27 | 28 | ### 开始 29 | 30 | 1. 克隆或下载本仓库到本地。 31 | 32 | ```shell 33 | git clone https://github.com/nepsyn/minaplay 34 | cd minaplay/packages/docs 35 | ``` 36 | 2. 使用包管理器安装依赖。 37 | 38 | ```shell 39 | npm install 40 | ``` 41 | 42 | 3. 在本地开发环境启动 MinaPlay 文档。 43 | 44 | ```shell 45 | npm run docs:dev 46 | ``` 47 | 48 | 4. 编译 MinaPlay 文档 49 | 50 | ```shell 51 | npm run docs:build 52 | ``` 53 | -------------------------------------------------------------------------------- /packages/docs/guide/common-rules.md: -------------------------------------------------------------------------------- 1 | # 常用订阅规则 2 | 3 | 这里列举了一些常见的订阅规则,读者可以根据自己的需要复制修改规则内容。 4 | 5 | ## 指定剧集下载 6 | 7 | 此订阅源会根据指定的剧集进行下载,并自动整理成 MinaPlay 中的剧集。 8 | 9 | ```typescript 10 | // 正则表达式应替换为发布组的标题格式 11 | const regexp = /\[Un-Sub\] NO GAME NO LIVE ([\d.]+)([vV]\d+)? \[1080P\]\[BDRip\]\[AAC AVC\]\[HEVC\]\[CHS\]/; 12 | const hooks: RuleHooks = { 13 | validate(entry) { 14 | return regexp.test(entry.title); 15 | }, 16 | describe(entry) { 17 | const groups = entry.title.match(regexp); 18 | return { 19 | series: { 20 | name: 'NO GAME NO LIVE', // 剧集的名称 21 | season: '01', // 剧集的季度 22 | }, 23 | episode: { 24 | title: entry.title, 25 | no: groups?.[1], 26 | }, 27 | overwriteEpisode: true, // 当存在重复单集时覆盖原有单集 28 | } 29 | } 30 | } 31 | export default hooks; 32 | ``` 33 | 34 | ## 关键词过滤(包含、不包含) 35 | 36 | 此规则会根据关键词判断是否下载媒体资源,但不会对下载的内容进行整理。 37 | 38 | ```typescript 39 | // 标题需要包含的内容 40 | const includes = ['1080P', 'CHS']; 41 | // 标题不能包含的内容 42 | const excludes = ['Un-Sub']; 43 | const hooks: RuleHooks = { 44 | validate: (entry) => { 45 | return includes.every((text) => entry.title.includes(text)) 46 | && !excludes.some((text) => entry.title.includes(text)); 47 | }, 48 | } 49 | export default hooks; 50 | ``` 51 | 52 | ## 全部下载 53 | 54 | _小孩子才做选择,我全都要!_ 55 | 56 | 此订阅规则会下载订阅源中的所有内容,无论用户是否需要。 57 | __注意__,在普遍情况下,下载订阅源中的所有内容会造成极大的资源开销。 58 | 除非您清楚订阅源中的所有内容都是必要的,否则不应该使用本规则。 59 | 60 | ```typescript 61 | const hooks: RuleHooks = { 62 | validate: (entry) => { 63 | return true; 64 | }, 65 | }; 66 | export default hooks; 67 | ``` -------------------------------------------------------------------------------- /packages/docs/guide/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | -------------------------------------------------------------------------------- /packages/docs/guide/proxy.md: -------------------------------------------------------------------------------- 1 | # 代理配置 -------------------------------------------------------------------------------- /packages/docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "MinaPlay" 7 | text: "个性化追番 / 追剧管家" 8 | tagline: 基于 RSS 的媒体文件自动下载管理工具 9 | actions: 10 | - theme: brand 11 | text: 快速开始 12 | link: /guide/getting-started 13 | - theme: alt 14 | text: GitHub 15 | link: https://github.com/nepsyn/minaplay 16 | 17 | features: 18 | - title: 自动解析 19 | details: 自动解析 RSS 订阅源,通过订阅规则个性化自己的追番 / 追剧媒体库。 20 | - title: 实时观影 21 | details: 支持多人实时观影,聊天、弹幕、多人语音,主打一个参与感~ 22 | - title: 插件系统 23 | details: 简单易用的插件系统,像使用命令行一样调用各种插件提供的服务。 24 | --- 25 | 26 | 30 | 31 | 37 | 38 |
39 | 40 | homepage 41 | -------------------------------------------------------------------------------- /packages/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@minaplay/docs", 3 | "version": "0.1.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "docs:dev": "vitepress dev --port 8080", 7 | "docs:build": "vitepress build", 8 | "docs:preview": "vitepress preview --port 8080" 9 | }, 10 | "devDependencies": { 11 | "vitepress": "^1.1.4" 12 | }, 13 | "dependencies": { 14 | "medium-zoom": "^1.1.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/docs/public/favicon.ico -------------------------------------------------------------------------------- /packages/docs/public/homepage-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/docs/public/homepage-dark.png -------------------------------------------------------------------------------- /packages/docs/public/homepage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/docs/public/homepage.png -------------------------------------------------------------------------------- /packages/docs/public/live-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/docs/public/live-dark.png -------------------------------------------------------------------------------- /packages/docs/public/live.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/docs/public/live.png -------------------------------------------------------------------------------- /packages/docs/public/minaplay.svg: -------------------------------------------------------------------------------- 1 | 2 | MinaPlay 3 | 4 | 5 | Layer 1 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /packages/docs/public/new-rss-source-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/docs/public/new-rss-source-dark.png -------------------------------------------------------------------------------- /packages/docs/public/new-rss-source.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/docs/public/new-rss-source.png -------------------------------------------------------------------------------- /packages/docs/public/new-rule-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/docs/public/new-rule-dark.png -------------------------------------------------------------------------------- /packages/docs/public/new-rule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/docs/public/new-rule.png -------------------------------------------------------------------------------- /packages/docs/public/plugin-console-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/docs/public/plugin-console-dark.png -------------------------------------------------------------------------------- /packages/docs/public/plugin-console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/docs/public/plugin-console.png -------------------------------------------------------------------------------- /packages/server/.env.template: -------------------------------------------------------------------------------- 1 | # Application 2 | APP_ENV=prod 3 | APP_HOST=0.0.0.0 4 | APP_PORT=3000 5 | APP_SECRET_KEY= 6 | APP_HTTP_PROXY= 7 | APP_GLOBAL_PROXY= 8 | APP_ENABLE_CORS=1 9 | 10 | # FFmpeg 11 | FFMPEG_PATH=ffmpeg 12 | FFPROBE_PATH=ffprobe 13 | 14 | # Database 15 | DB_HOST=localhost 16 | DB_PORT=3306 17 | DB_USERNAME=root 18 | DB_PASSWORD= 19 | DB_DATABASE=minaplay 20 | DB_SSL_CA= 21 | 22 | # Redis 23 | REDIS_HOST=localhost 24 | REDIS_PORT=6379 25 | REDIS_DB=0 26 | REDIS_PASSWORD= 27 | 28 | # Downloader 29 | DOWNLOAD_ADAPTER=aria2 30 | DOWNLOAD_AUTO_UPDATE_TRACKER=1 31 | DOWNLOAD_TRACKER_LIST_URL="https://cdn.jsdelivr.net/gh/ngosang/trackerslist@master/trackers_best.txt" 32 | 33 | # Aria2 34 | ARIA2_RPC_HOST=127.0.0.1 35 | ARIA2_RPC_PORT=6800 36 | ARIA2_RPC_PATH=/jsonrpc 37 | ARIA2_RPC_SECRET= 38 | 39 | # Mediasoup 40 | MS_ANNOUNCED_ADDRESS=127.0.0.1 41 | MS_RTC_MIN_PORT=12000 42 | MS_RTC_MAX_PORT=12100 43 | MS_WORKERS_NUM=4 44 | MS_AUDIO_CLOCK_RATE=48000 45 | MS_AUDIO_CHANNELS=2 46 | MS_AUDIO_MAX_INCOME_BITRATE=1500000 47 | 48 | # Live Stream 49 | STREAM_RTMP_PORT=1935 50 | STREAM_HTTP_PORT=3001 51 | STREAM_CHUNK_SIZE=60000 52 | STREAM_PUBLISH_KEY= 53 | 54 | # Notifications 55 | # WS 56 | NOTIFY_WS=1 57 | 58 | # Email 59 | NOTIFY_EMAIL=0 60 | NOTIFY_EMAIL_SMTP_HOST=mail.example.com 61 | NOTIFY_EMAIL_SMTP_PORT=25 62 | NOTIFY_EMAIL_SMTP_SECURE=0 63 | NOTIFY_EMAIL_SMTP_USER=no-reply@example.com 64 | NOTIFY_EMAIL_SMTP_PASSWORD=password 65 | NOTIFY_EMAIL_ORIGIN="MinaPlay " 66 | NOTIFY_EMAIL_SUBJECT="MinaPlay Email Notification" 67 | 68 | # ServerChan 69 | NOTIFY_SERVER_CHAN=0 70 | 71 | # Telegram 72 | NOTIFY_TELEGRAM=0 73 | -------------------------------------------------------------------------------- /packages/server/.prettierignore: -------------------------------------------------------------------------------- 1 | migrations/ 2 | -------------------------------------------------------------------------------- /packages/server/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true, 7 | "assets":["**/*.handlebars"], 8 | "watchAssets": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/server/src/common/api.file.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ApiBody } from '@nestjs/swagger'; 2 | 3 | export const ApiFile = 4 | (fieldName = 'file', description = '文件'): MethodDecorator => 5 | (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { 6 | ApiBody({ 7 | schema: { 8 | type: 'object', 9 | properties: { 10 | [fieldName]: { 11 | type: 'file', 12 | format: 'binary', 13 | description, 14 | }, 15 | }, 16 | }, 17 | })(target, propertyKey, descriptor); 18 | }; 19 | -------------------------------------------------------------------------------- /packages/server/src/common/api.pagination.result.dto.ts: -------------------------------------------------------------------------------- 1 | export class ApiPaginationResultDto { 2 | constructor(public items: T[], public total: number, public page: number, public size: number) {} 3 | } 4 | -------------------------------------------------------------------------------- /packages/server/src/common/api.query.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Transform } from 'class-transformer'; 3 | import { IsInt, IsOptional, IsString } from 'class-validator'; 4 | import { FindOptionsOrder } from 'typeorm'; 5 | 6 | export class ApiQueryDto { 7 | @ApiProperty({ 8 | description: '分页页数', 9 | type: Number, 10 | required: false, 11 | default: 0, 12 | }) 13 | @IsOptional() 14 | @IsInt() 15 | @Transform(({ value }) => Number(value)) 16 | page = 0; 17 | 18 | @ApiProperty({ 19 | description: '分页大小', 20 | type: Number, 21 | required: false, 22 | default: 40, 23 | }) 24 | @IsOptional() 25 | @IsInt() 26 | @Transform(({ value }) => { 27 | const size = Number(value); 28 | return size < 0 ? 9999 : size; 29 | }) 30 | size = 40; 31 | 32 | @ApiProperty({ 33 | description: '排序字段', 34 | type: [String], 35 | required: false, 36 | default: ['createAt:ASC'], 37 | }) 38 | @IsString({ each: true }) 39 | @Transform(({ value }) => (typeof value === 'string' ? [value] : value)) 40 | sort: `${keyof T & string}:${'ASC' | 'DESC'}`[] = ['createAt:ASC']; 41 | 42 | get sortBy(): FindOptionsOrder { 43 | return Object.fromEntries(this.sort.map((sort) => sort.split(':'))); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/server/src/common/application.exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, HttpException } from '@nestjs/common'; 2 | import { BaseExceptionFilter } from '@nestjs/core'; 3 | import { Response } from 'express'; 4 | import { QueryFailedError } from 'typeorm'; 5 | import { ErrorCodeEnum } from '../enums/error-code.enum.js'; 6 | import { ApplicationLogger } from './application.logger.service.js'; 7 | 8 | @Catch(Error) 9 | export class ApplicationExceptionFilter extends BaseExceptionFilter { 10 | private readonly logger = new ApplicationLogger(ApplicationExceptionFilter.name); 11 | 12 | catch(exception: Error, host: ArgumentsHost): any { 13 | const response = host.switchToHttp().getResponse(); 14 | 15 | if (exception instanceof HttpException) { 16 | const data = exception.getResponse(); 17 | response.status(exception.getStatus()).json({ 18 | code: data?.['code'] ?? ErrorCodeEnum.UNKNOWN_ERROR, 19 | message: data?.['message'] ?? exception.message, 20 | }); 21 | } else if (exception instanceof QueryFailedError) { 22 | response.status(500).json({ 23 | code: ErrorCodeEnum.QUERY_FAILED, 24 | message: 'QUERY FAILED', 25 | }); 26 | this.logger.error(exception.message, exception.stack, ApplicationExceptionFilter.name); 27 | } else { 28 | response.status(exception?.['statusCode'] ?? exception?.['status'] ?? 500).json({ 29 | code: ErrorCodeEnum.INTERNAL_SERVER_ERROR, 30 | message: 'INTERNAL SERVER ERROR', 31 | }); 32 | this.logger.error(exception.message, exception.stack, ApplicationExceptionFilter.name); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/server/src/common/application.gateway.exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, HttpException } from '@nestjs/common'; 2 | import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets'; 3 | import { Socket } from 'socket.io'; 4 | import { ErrorCodeEnum } from '../enums/error-code.enum.js'; 5 | import { ApplicationLogger } from './application.logger.service.js'; 6 | 7 | @Catch(Error) 8 | export class ApplicationGatewayExceptionFilter extends BaseWsExceptionFilter { 9 | private readonly logger = new ApplicationLogger(ApplicationGatewayExceptionFilter.name); 10 | 11 | catch(exception: Error, host: ArgumentsHost) { 12 | const socket: Socket = host.switchToWs().getClient(); 13 | const syncId: number = host.switchToWs().getData().sync; 14 | if (exception instanceof WsException) { 15 | const error = exception.getError(); 16 | socket.emit('exception', { 17 | sync: syncId, 18 | code: error?.['code'] ?? ErrorCodeEnum.UNKNOWN_ERROR, 19 | message: error?.['message'] ?? exception.message, 20 | }); 21 | } else if (exception instanceof HttpException) { 22 | const error = exception.getResponse(); 23 | socket.emit('exception', { 24 | sync: syncId, 25 | code: error?.['code'] ?? ErrorCodeEnum.UNKNOWN_ERROR, 26 | message: error?.['message'] ?? exception.message, 27 | }); 28 | } else { 29 | socket.emit('exception', { 30 | sync: syncId, 31 | code: exception?.['status'] ?? ErrorCodeEnum.INTERNAL_SERVER_ERROR, 32 | message: exception?.['message'] ?? 'INTERNAL SERVER ERROR', 33 | }); 34 | this.logger.error(exception.message, exception.stack, ApplicationGatewayExceptionFilter.name); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/server/src/common/application.gateway.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; 2 | import { WsException } from '@nestjs/websockets'; 3 | import { map, Observable } from 'rxjs'; 4 | import { Socket } from 'socket.io'; 5 | import { ErrorCodeEnum } from '../enums/error-code.enum.js'; 6 | import { buildException } from '../utils/build-exception.util.js'; 7 | import { isDefined } from 'class-validator'; 8 | 9 | @Injectable() 10 | export class ApplicationGatewayInterceptor implements NestInterceptor { 11 | async intercept(context: ExecutionContext, next: CallHandler): Promise> { 12 | const socket: Socket = context.switchToWs().getClient(); 13 | const syncId: number = context.switchToWs().getData().sync; 14 | if (!isDefined(syncId)) { 15 | throw buildException(WsException, ErrorCodeEnum.NO_SYNC_FIELD); 16 | } 17 | 18 | return next.handle().pipe( 19 | map((data) => { 20 | const response = { 21 | sync: syncId, 22 | data: data ?? {}, 23 | }; 24 | socket.emit('response', response); 25 | }), 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/server/src/common/application.logger.service.ts: -------------------------------------------------------------------------------- 1 | import { ConsoleLogger, LogLevel } from '@nestjs/common'; 2 | import { ConsoleLoggerOptions } from '@nestjs/common/services/console-logger.service.js'; 3 | 4 | export class ApplicationLogger extends ConsoleLogger { 5 | private static messages: string[] = []; 6 | 7 | constructor(context: string = 'APP', options?: ConsoleLoggerOptions) { 8 | super(context, { 9 | ...options, 10 | prefix: 'MinaPlay', 11 | }); 12 | } 13 | 14 | public static addHistoryMessage(message: string) { 15 | if (this.messages.length > 512) { 16 | this.messages.shift(); 17 | } 18 | this.messages.push(message); 19 | } 20 | 21 | public static getHistoryMessages() { 22 | return [].concat(this.messages); 23 | } 24 | 25 | public static clearMessages() { 26 | this.messages = []; 27 | } 28 | 29 | protected printMessages( 30 | messages: unknown[], 31 | context?: string, 32 | logLevel?: LogLevel, 33 | writeStreamType?: 'stdout' | 'stderr', 34 | ) { 35 | messages.forEach((message) => { 36 | const pidMessage = this.formatPid(process.pid); 37 | const contextMessage = this.formatContext(context); 38 | const timestampDiff = this.updateAndGetTimestampDiff(); 39 | const formattedLogLevel = logLevel.toUpperCase().padStart(7, ' '); 40 | const formattedMessage = this.formatMessage( 41 | logLevel, 42 | message, 43 | pidMessage, 44 | formattedLogLevel, 45 | contextMessage, 46 | timestampDiff, 47 | ); 48 | 49 | ApplicationLogger.addHistoryMessage(formattedMessage); 50 | process[writeStreamType ?? 'stdout'].write(formattedMessage); 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/server/src/common/application.message.ts: -------------------------------------------------------------------------------- 1 | import { ClassConstructor, plainToInstance } from 'class-transformer'; 2 | import { validateSync } from 'class-validator'; 3 | import { 4 | Action, 5 | Base64Image, 6 | ConsumableGroup, 7 | Consumed, 8 | MarkdownText, 9 | NetworkImage, 10 | Pending, 11 | ResourceMedia, 12 | ResourceSeries, 13 | Text, 14 | Timeout, 15 | } from './messages/index.js'; 16 | 17 | export const MinaPlayMessageMap = { 18 | Text, 19 | MarkdownText, 20 | NetworkImage, 21 | Base64Image, 22 | Action, 23 | Timeout, 24 | ConsumableGroup, 25 | Consumed, 26 | Pending, 27 | ResourceSeries, 28 | ResourceMedia, 29 | }; 30 | export type MinaPlayMessage = 31 | | Text 32 | | MarkdownText 33 | | NetworkImage 34 | | Base64Image 35 | | Action 36 | | Timeout 37 | | ConsumableGroup 38 | | Consumed 39 | | Pending 40 | | ResourceSeries 41 | | ResourceMedia; 42 | export type MinaPlayMessageType = MinaPlayMessage['type']; 43 | 44 | /** 通过对象构造消息类型 */ 45 | export function parseMessage(plainObject: T): T | null { 46 | if (!plainObject || !plainObject.type || !MinaPlayMessageMap[plainObject.type]) { 47 | return null; 48 | } 49 | 50 | const message = plainToInstance(MinaPlayMessageMap[plainObject.type] as ClassConstructor, plainObject, { 51 | excludeExtraneousValues: true, 52 | }); 53 | const errors = validateSync(message); 54 | 55 | return errors.length === 0 ? message : null; 56 | } 57 | -------------------------------------------------------------------------------- /packages/server/src/common/application.timeout.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor, RequestTimeoutException } from '@nestjs/common'; 2 | import { catchError, Observable, throwError, timeout, TimeoutError } from 'rxjs'; 3 | import { Reflector } from '@nestjs/core'; 4 | import { REQUEST_TIMEOUT_KEY } from './request.timeout.decorator.js'; 5 | import { buildException } from '../utils/build-exception.util.js'; 6 | import { ErrorCodeEnum } from '../enums/error-code.enum.js'; 7 | 8 | @Injectable() 9 | export class ApplicationTimeoutInterceptor implements NestInterceptor { 10 | constructor(private reflector: Reflector) {} 11 | 12 | async intercept(context: ExecutionContext, next: CallHandler): Promise> { 13 | const ms = 14 | this.reflector.getAllAndOverride(REQUEST_TIMEOUT_KEY, [context.getHandler(), context.getClass()]) ?? 5000; 15 | 16 | if (ms > 0) { 17 | return next.handle().pipe( 18 | timeout(ms), 19 | catchError((error) => { 20 | if (error instanceof TimeoutError) { 21 | return throwError(() => buildException(RequestTimeoutException, ErrorCodeEnum.TIMEOUT)); 22 | } 23 | return throwError(() => error); 24 | }), 25 | ); 26 | } else { 27 | return next.handle(); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/server/src/common/messages/action.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { Equals, IsString, ValidateNested } from 'class-validator'; 3 | import { Text } from './text.js'; 4 | 5 | export class Action { 6 | @Expose() 7 | @Equals('Action') 8 | type: 'Action' = 'Action'; 9 | 10 | /** value */ 11 | @Expose() 12 | @IsString() 13 | value: string; 14 | 15 | /** Action Text */ 16 | @Expose() 17 | @ValidateNested() 18 | text: Text; 19 | 20 | constructor(value: string, text: Text) { 21 | this.value = value; 22 | this.text = text; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/server/src/common/messages/base64-image.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { Equals, IsBase64 } from 'class-validator'; 3 | 4 | /** base64 Image */ 5 | export class Base64Image { 6 | @Expose() 7 | @Equals('Base64Image') 8 | type: 'Base64Image' = 'Base64Image'; 9 | 10 | /** base64 content */ 11 | @Expose() 12 | @IsBase64() 13 | base64: string; 14 | 15 | constructor(base64: string) { 16 | this.base64 = base64; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/server/src/common/messages/consumable-group.ts: -------------------------------------------------------------------------------- 1 | import { Expose, Type } from 'class-transformer'; 2 | import { Equals, IsString, ValidateNested } from 'class-validator'; 3 | import { MinaPlayMessage, MinaPlayMessageMap } from '../application.message.js'; 4 | 5 | /** Consumable Group */ 6 | export class ConsumableGroup { 7 | @Expose() 8 | @Equals('ConsumableGroup') 9 | type: 'ConsumableGroup' = 'ConsumableGroup'; 10 | 11 | /** ID */ 12 | @Expose() 13 | @IsString() 14 | id: string; 15 | 16 | /** Actions */ 17 | @Expose() 18 | @Type(({ object }) => MinaPlayMessageMap[object?.['type']]) 19 | @ValidateNested() 20 | items: MinaPlayMessage[]; 21 | 22 | constructor(id: string, items: MinaPlayMessage[]) { 23 | this.id = id; 24 | this.items = items; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/server/src/common/messages/consumed.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { Equals, IsString } from 'class-validator'; 3 | 4 | /** Consumed */ 5 | export class Consumed { 6 | @Expose() 7 | @Equals('Consumed') 8 | type: 'Consumed' = 'Consumed'; 9 | 10 | /** ID */ 11 | @Expose() 12 | @IsString() 13 | id: string; 14 | 15 | constructor(id: string) { 16 | this.id = id; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/server/src/common/messages/index.ts: -------------------------------------------------------------------------------- 1 | export * from './text.js'; 2 | export * from './markdown-text.js'; 3 | export * from './network-image.js'; 4 | export * from './base64-image.js'; 5 | export * from './action.js'; 6 | export * from './timeout.js'; 7 | export * from './consumable-group.js'; 8 | export * from './consumed.js'; 9 | export * from './pending.js'; 10 | 11 | export * from './resource-series.js'; 12 | export * from './resource-media.js'; 13 | -------------------------------------------------------------------------------- /packages/server/src/common/messages/markdown-text.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { Equals, IsString } from 'class-validator'; 3 | 4 | /** Markdown Text */ 5 | export class MarkdownText { 6 | @Expose() 7 | @Equals('MarkdownText') 8 | type: 'MarkdownText' = 'MarkdownText'; 9 | 10 | /** Content */ 11 | @Expose() 12 | @IsString() 13 | content: string; 14 | 15 | constructor(content: string) { 16 | this.content = content; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/server/src/common/messages/network-image.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { Equals, IsUrl } from 'class-validator'; 3 | 4 | /** Network Image */ 5 | export class NetworkImage { 6 | @Expose() 7 | @Equals('NetworkImage') 8 | type: 'NetworkImage' = 'NetworkImage'; 9 | 10 | /** URL */ 11 | @Expose() 12 | @IsUrl() 13 | url: string; 14 | 15 | constructor(url: string) { 16 | this.url = url; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/server/src/common/messages/pending.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { Equals, IsHexColor, IsOptional } from 'class-validator'; 3 | 4 | /** Pending */ 5 | export class Pending { 6 | @Expose() 7 | @Equals('Pending') 8 | type: 'Pending' = 'Pending'; 9 | 10 | /** Color */ 11 | @Expose() 12 | @IsHexColor() 13 | @IsOptional() 14 | color?: string; 15 | 16 | constructor(color?: string) { 17 | this.color = color; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/server/src/common/messages/resource-media.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { Equals } from 'class-validator'; 3 | import { Media } from '../../modules/media/media.entity.js'; 4 | 5 | /** Resource Media */ 6 | export class ResourceMedia { 7 | @Expose() 8 | @Equals('ResourceMedia') 9 | type: 'ResourceMedia' = 'ResourceMedia'; 10 | 11 | /** Content */ 12 | @Expose() 13 | media: Media; 14 | 15 | constructor(media: Media) { 16 | this.media = media; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/server/src/common/messages/resource-series.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { Equals } from 'class-validator'; 3 | import { Series } from '../../modules/media/series/series.entity.js'; 4 | 5 | /** Resource Series */ 6 | export class ResourceSeries { 7 | @Expose() 8 | @Equals('ResourceSeries') 9 | type: 'ResourceSeries' = 'ResourceSeries'; 10 | 11 | /** Content */ 12 | @Expose() 13 | series: Series; 14 | 15 | constructor(series: Series) { 16 | this.series = series; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/server/src/common/messages/text.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { Equals, IsHexColor, IsOptional, IsString } from 'class-validator'; 3 | 4 | /** Plain Text */ 5 | export class Text { 6 | static Colors = { 7 | INFO: '#0288d1', 8 | WARNING: '#ed6c02', 9 | SUCCESS: '#2e7d32', 10 | ERROR: '#d32f2f', 11 | }; 12 | 13 | @Expose() 14 | @Equals('Text') 15 | type: 'Text' = 'Text'; 16 | 17 | /** Color */ 18 | @Expose() 19 | @IsHexColor() 20 | @IsOptional() 21 | color?: string; 22 | 23 | /** Content */ 24 | @Expose() 25 | @IsString() 26 | content: string; 27 | 28 | constructor(content: string, color?: string) { 29 | this.content = content; 30 | this.color = color; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/server/src/common/messages/timeout.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { Equals, IsNumber } from 'class-validator'; 3 | 4 | /** Timeout */ 5 | export class Timeout { 6 | @Expose() 7 | @Equals('Timeout') 8 | type: 'Timeout' = 'Timeout'; 9 | 10 | /** timeout */ 11 | @Expose() 12 | @IsNumber() 13 | ms: number; 14 | 15 | constructor(ms: number) { 16 | this.ms = ms; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/server/src/common/request.ip.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const RequestIp = createParamDecorator((data: unknown, ctx: ExecutionContext) => { 4 | if (ctx.getType() === 'http') { 5 | const request = ctx.switchToHttp().getRequest(); 6 | return request.headers['x-forwarded-for'] ?? request.connection?.remoteAddress ?? request.ip; 7 | } else if (ctx.getType() === 'ws') { 8 | const socket = ctx.switchToWs().getClient(); 9 | return socket.handshake?.address; 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /packages/server/src/common/request.timeout.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const REQUEST_TIMEOUT_KEY = 'REQUEST_TIMEOUT'; 4 | 5 | /** 6 | * 接口超时时间 7 | * @param timeout 超时时间(ms) 8 | */ 9 | export const RequestTimeout = (timeout: number) => SetMetadata(REQUEST_TIMEOUT_KEY, timeout); 10 | -------------------------------------------------------------------------------- /packages/server/src/common/request.user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const RequestUser = createParamDecorator((data: unknown, ctx: ExecutionContext) => { 4 | if (ctx.getType() === 'http') { 5 | return ctx.switchToHttp().getRequest().user; 6 | } else if (ctx.getType() === 'ws') { 7 | return ctx.switchToWs().getClient().data?.user; 8 | } 9 | }); 10 | -------------------------------------------------------------------------------- /packages/server/src/common/socket-io.adapter.ts: -------------------------------------------------------------------------------- 1 | import { INestApplicationContext } from '@nestjs/common'; 2 | import { IoAdapter } from '@nestjs/platform-socket.io'; 3 | import { ServerOptions } from 'socket.io'; 4 | import { ConfigService } from '@nestjs/config'; 5 | 6 | export class SocketIOAdapter extends IoAdapter { 7 | constructor(app: INestApplicationContext, private configService: ConfigService) { 8 | super(app); 9 | } 10 | 11 | createIOServer(port: number, options?: ServerOptions) { 12 | if (Number(this.configService.get('APP_ENABLE_CORS', 0)) === 1) { 13 | options.cors = { 14 | origin: '*', 15 | }; 16 | } 17 | return super.createIOServer(port, options); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/server/src/constants.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { fileURLToPath } from 'node:url'; 3 | import fs from 'fs-extra'; 4 | 5 | export const DATA_DIR = path.join(process.cwd(), 'data'); 6 | 7 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 8 | export const MINAPLAY_VERSION = fs.readJSONSync(path.join(__dirname, '../package.json')).version; 9 | 10 | export const USER_UPLOAD_IMAGE_DIR = path.join(DATA_DIR, 'upload/image'); 11 | export const USER_UPLOAD_VIDEO_DIR = path.join(DATA_DIR, 'upload/video'); 12 | export const DOWNLOAD_DIR = path.join(DATA_DIR, 'download'); 13 | export const INDEX_DIR = path.join(DATA_DIR, 'index'); 14 | export const RULE_CODE_DIR = path.join(DATA_DIR, 'rule'); 15 | export const GENERATED_DIR = path.join(DATA_DIR, 'generated'); 16 | export const LIVE_STREAM_DIR = path.join(DATA_DIR, 'live'); 17 | export const PLUGIN_DIR = path.join(DATA_DIR, 'plugin'); 18 | export const TEMPLATE_DIR = path.join(DATA_DIR, 'template'); 19 | 20 | export const VALID_IMAGE_MIME = ['image/png', 'image/jpeg', 'image/gif']; 21 | export const VALID_VIDEO_MIME = [ 22 | 'video/mp4', 23 | 'video/mpeg', 24 | 'video/quicktime', 25 | 'video/x-flv', 26 | 'video/x-ms-wmv', 27 | 'video/ogg', 28 | 'video/h264', 29 | 'video/h265', 30 | 'video/x-matroska', 31 | 'video/webm', 32 | ]; 33 | -------------------------------------------------------------------------------- /packages/server/src/enums/auth-action.enum.ts: -------------------------------------------------------------------------------- 1 | export enum AuthActionEnum { 2 | LOGIN = 'LOGIN', 3 | LOGOUT = 'LOGOUT', 4 | REFRESH = 'REFRESH', 5 | GRANT = 'GRANT', 6 | CHANGE_PASSWORD = 'CHANGE_PASSWORD', 7 | } 8 | -------------------------------------------------------------------------------- /packages/server/src/enums/error-code.enum.ts: -------------------------------------------------------------------------------- 1 | /** 应用程序错误代码 */ 2 | export enum ErrorCodeEnum { 3 | /** 错误的请求 */ 4 | BAD_REQUEST = 0x0001, 5 | /** 服务器内部错误 */ 6 | INTERNAL_SERVER_ERROR = 0x0002, 7 | /** 错误的查询 */ 8 | QUERY_FAILED = 0x0003, 9 | /** 系统未记录错误 */ 10 | UNKNOWN_ERROR = 0x0004, 11 | /** 缺少同步字段 */ 12 | NO_SYNC_FIELD = 0x0005, 13 | /** 请求资源不存在 */ 14 | NOT_FOUND = 0x0006, 15 | /** 处理超时 */ 16 | TIMEOUT = 0x0007, 17 | /** 未实现 */ 18 | NOT_IMPLEMENTED = 0x0008, 19 | 20 | /** 用户名或密码错误 */ 21 | WRONG_USERNAME_OR_PASSWORD = 0x0101, 22 | /** 用户未登录 */ 23 | USER_NOT_LOGGED_IN = 0x0102, 24 | /** 用户缺少权限 */ 25 | NO_PERMISSION = 0x0103, 26 | /** 未经授权的 Token */ 27 | INVALID_TOKEN = 0x0104, 28 | /** 用户名已被使用 */ 29 | USERNAME_ALREADY_OCCUPIED = 0x0105, 30 | 31 | /** 错误的文件内容 */ 32 | INVALID_FILE = 0x0301, 33 | /** 错误的图片文件类型 */ 34 | INVALID_IMAGE_FILE_TYPE = 0x0302, 35 | /** 错误的视频文件类型 */ 36 | INVALID_VIDEO_FILE_TYPE = 0x0303, 37 | 38 | /** 剧集已存在 */ 39 | DUPLICATE_SERIES = 0x0401, 40 | 41 | /** 订阅源格式错误 */ 42 | INVALID_SUBSCRIBE_SOURCE_FORMAT = 0x0501, 43 | /** 订阅规则代码错误 */ 44 | INVALID_SUBSCRIBE_RULE_CODE = 0x0502, 45 | /** 重复下载 */ 46 | DUPLICATED_DOWNLOAD_ITEM = 0x0503, 47 | 48 | /** 用户被禁止发言 */ 49 | USER_CHAT_MUTED = 0x0601, 50 | /** 用户被禁止语音 */ 51 | USER_VOICE_MUTED = 0x0602, 52 | /** 直播语音连接建立失败 */ 53 | VOICE_SERVICE_ESTABLISH_FAILED = 0x0603, 54 | /** 直播房间密码错误 */ 55 | WRONG_LIVE_PASSWORD = 0x0604, 56 | /** 存在多个连接 */ 57 | DUPLICATED_CONNECTION = 0x0605, 58 | 59 | /** 重复的通知服务 */ 60 | DUPLICATED_NOTIFICATION_SERVICE = 0x0701, 61 | /** 邮箱验证码错误 */ 62 | WRONG_EMAIL_VERIFY_CODE = 0x0702, 63 | 64 | /** 内置插件不可卸载 */ 65 | BUILTIN_PLUGIN_NOT_UNINSTALLABLE = 0x0801, 66 | } 67 | -------------------------------------------------------------------------------- /packages/server/src/enums/file-source.enum.ts: -------------------------------------------------------------------------------- 1 | export enum FileSourceEnum { 2 | USER_UPLOAD = 'USER_UPLOAD', 3 | DOWNLOAD = 'DOWNLOAD', 4 | AUTO_GENERATED = 'AUTO_GENERATED', 5 | LOCAL = 'LOCAL', 6 | NETWORK = 'NETWORK', 7 | } 8 | -------------------------------------------------------------------------------- /packages/server/src/enums/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error-code.enum.js'; 2 | export * from './auth-action.enum.js'; 3 | export * from './file-source.enum.js'; 4 | export * from './permission.enum.js'; 5 | export * from './status.enum.js'; 6 | export * from './notification-event.enum.js'; 7 | export * from './notification-service.enum.js'; 8 | -------------------------------------------------------------------------------- /packages/server/src/enums/notification-event.enum.ts: -------------------------------------------------------------------------------- 1 | export enum NotificationEventEnum { 2 | NEW_EPISODE = 'new-episode', 3 | NEW_MEDIA = 'new-media', 4 | } 5 | -------------------------------------------------------------------------------- /packages/server/src/enums/notification-service.enum.ts: -------------------------------------------------------------------------------- 1 | export enum NotificationServiceEnum { 2 | EMAIL = 'EMAIL', 3 | WS = 'WS', 4 | SERVER_CHAN = 'SERVER_CHAN', 5 | TELEGRAM = 'TELEGRAM', 6 | } 7 | -------------------------------------------------------------------------------- /packages/server/src/enums/permission.enum.ts: -------------------------------------------------------------------------------- 1 | export enum PermissionEnum { 2 | /** 最高权限 */ 3 | ROOT_OP = '*:*', 4 | 5 | /** 文件管理 */ 6 | FILE_OP = 'FILE:*', 7 | /** 上传图片 */ 8 | FILE_UPLOAD_IMAGE = 'FILE:UPLOAD:IMAGE', 9 | /** 上传视频 */ 10 | FILE_UPLOAD_VIDEO = 'FILE:UPLOAD:VIDEO', 11 | 12 | /** 媒体管理 */ 13 | MEDIA_OP = 'MEDIA:*', 14 | /** 媒体查看 */ 15 | MEDIA_VIEW = 'MEDIA:VIEW', 16 | 17 | /** 订阅管理 */ 18 | SUBSCRIBE_OP = 'SUBSCRIBE:*', 19 | /** 订阅查看 */ 20 | SUBSCRIBE_VIEW = 'SUBSCRIBE:VIEW', 21 | 22 | /** 直播管理 */ 23 | LIVE_OP = 'LIVE:*', 24 | /** 直播查看 */ 25 | LIVE_VIEW = 'LIVE:VIEW', 26 | } 27 | -------------------------------------------------------------------------------- /packages/server/src/enums/status.enum.ts: -------------------------------------------------------------------------------- 1 | export enum StatusEnum { 2 | PENDING = 'PENDING', 3 | PAUSED = 'PAUSED', 4 | SUCCESS = 'SUCCESS', 5 | FAILED = 'FAILED', 6 | } 7 | -------------------------------------------------------------------------------- /packages/server/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common/messages/index.js'; 2 | export * from './common/application.message.js'; 3 | export * from './common/application.logger.service.js'; 4 | export * from './common/api.query.dto.js'; 5 | export * from './common/api.pagination.result.dto.js'; 6 | 7 | export * from './enums/index.js'; 8 | 9 | export * from './modules/index.js'; 10 | 11 | export * from './constants.js'; 12 | -------------------------------------------------------------------------------- /packages/server/src/interfaces/media.metadata.d.ts: -------------------------------------------------------------------------------- 1 | export interface MediaMetadata { 2 | streams: { 3 | index: number; 4 | codec_name: string; 5 | codec_type: string; 6 | duration?: string; 7 | tags?: { 8 | DURATION?: string; 9 | title?: string; 10 | filename?: string; 11 | mimetype?: string; 12 | }; 13 | }[]; 14 | } 15 | -------------------------------------------------------------------------------- /packages/server/src/interfaces/shims.extractor.d.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { type FeedEntry } from '@extractus/feed-extractor'; 3 | 4 | declare module '@extractus/feed-extractor' { 5 | export interface FeedEntry { 6 | enclosure: { 7 | url: string; 8 | type?: string; 9 | }; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/server/src/migrations/1714291901324-AddRuleParserMeta.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class AddRuleParserMeta1714291901324 implements MigrationInterface { 4 | name = 'AddRuleParserMeta1714291901324' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(`ALTER TABLE \`source\` ADD \`parserMeta\` varchar(255) NULL`); 8 | await queryRunner.query(`ALTER TABLE \`rule\` ADD \`parserMeta\` varchar(255) NULL`); 9 | await queryRunner.query(`ALTER TABLE \`rule\` DROP FOREIGN KEY \`FK_2952a30d05d90b805c055aa7886\``); 10 | await queryRunner.query(`ALTER TABLE \`rule\` ADD CONSTRAINT \`FK_2952a30d05d90b805c055aa7886\` FOREIGN KEY (\`fileId\`) REFERENCES \`file\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION`); 11 | } 12 | 13 | public async down(queryRunner: QueryRunner): Promise { 14 | await queryRunner.query(`ALTER TABLE \`rule\` DROP COLUMN \`parserMeta\``); 15 | await queryRunner.query(`ALTER TABLE \`source\` DROP COLUMN \`parserMeta\``); 16 | await queryRunner.query(`ALTER TABLE \`rule\` DROP FOREIGN KEY \`FK_2952a30d05d90b805c055aa7886\``); 17 | await queryRunner.query(`ALTER TABLE \`rule\` ADD CONSTRAINT \`FK_2952a30d05d90b805c055aa7886\` FOREIGN KEY (\`fileId\`) REFERENCES \`file\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`); 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /packages/server/src/migrations/1744702921270-ModifyFileColumnSize.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class ModifyFileColumnSize1744702921270 implements MigrationInterface { 4 | name = 'ModifyFileColumnSize1744702921270' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(`ALTER TABLE \`file\` MODIFY COLUMN \`filename\` varchar(512) NULL`); 8 | await queryRunner.query(`ALTER TABLE \`file\` MODIFY COLUMN \`name\` varchar(512) NOT NULL`); 9 | await queryRunner.query(`ALTER TABLE \`file\` MODIFY COLUMN \`size\` bigint UNSIGNED NULL`); 10 | await queryRunner.query(`ALTER TABLE \`file\` MODIFY COLUMN \`path\` varchar(4096) NOT NULL`); 11 | } 12 | 13 | public async down(queryRunner: QueryRunner): Promise { 14 | await queryRunner.query(`ALTER TABLE \`file\` MODIFY COLUMN \`path\` varchar(1024) NOT NULL`); 15 | await queryRunner.query(`ALTER TABLE \`file\` MODIFY COLUMN \`size\` int NULL`); 16 | await queryRunner.query(`ALTER TABLE \`file\` MODIFY COLUMN \`name\` varchar(255) NOT NULL`); 17 | await queryRunner.query(`ALTER TABLE \`file\` MODIFY COLUMN \`filename\` varchar(255) NULL`); 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /packages/server/src/modules/authorization/action-log-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiQueryDto } from '../../common/api.query.dto.js'; 2 | import { ActionLog } from './action-log.entity.js'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | import { IsDateString, IsEnum, IsInt, IsOptional, IsString } from 'class-validator'; 5 | import { Transform } from 'class-transformer'; 6 | import { AuthActionEnum } from '../../enums/auth-action.enum.js'; 7 | 8 | export class ActionLogQueryDto extends ApiQueryDto { 9 | @ApiProperty({ 10 | description: '操作用户id', 11 | required: false, 12 | }) 13 | @Transform(({ value }) => Number(value)) 14 | @IsOptional() 15 | @IsInt() 16 | operatorId?: number; 17 | 18 | @ApiProperty({ 19 | description: '操作用户IP', 20 | required: false, 21 | }) 22 | @IsOptional() 23 | @IsString() 24 | ip?: string; 25 | 26 | @ApiProperty({ 27 | description: '操作类型', 28 | required: false, 29 | }) 30 | @IsOptional() 31 | @IsEnum(AuthActionEnum) 32 | action?: AuthActionEnum; 33 | 34 | @ApiProperty({ 35 | description: '开始时间', 36 | required: false, 37 | }) 38 | @IsOptional() 39 | @IsDateString() 40 | start?: string; 41 | 42 | @ApiProperty({ 43 | description: '结束时间', 44 | required: false, 45 | }) 46 | @IsOptional() 47 | @IsDateString() 48 | end?: string; 49 | } 50 | -------------------------------------------------------------------------------- /packages/server/src/modules/authorization/action-log.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, Relation } from 'typeorm'; 2 | import { User } from '../user/user.entity.js'; 3 | import { AuthActionEnum } from '../../enums/index.js'; 4 | 5 | /** 操作日志 */ 6 | @Entity() 7 | export class ActionLog { 8 | /** id */ 9 | @PrimaryGeneratedColumn('uuid') 10 | id: string; 11 | 12 | /** 操作者ip */ 13 | @Column() 14 | ip?: string; 15 | 16 | /** 操作类型 */ 17 | @Column() 18 | action: AuthActionEnum; 19 | 20 | /** 操作人 */ 21 | @ManyToOne(() => User, { 22 | onDelete: 'CASCADE', 23 | eager: true, 24 | }) 25 | operator: Relation; 26 | 27 | /** 目标用户 */ 28 | @ManyToOne(() => User, { 29 | onDelete: 'CASCADE', 30 | eager: true, 31 | }) 32 | target: Relation; 33 | 34 | /** 额外参数 */ 35 | @Column({ 36 | type: 'text', 37 | nullable: true, 38 | }) 39 | extra?: string; 40 | 41 | /** 创建时间 */ 42 | @CreateDateColumn() 43 | createAt: Date; 44 | } 45 | -------------------------------------------------------------------------------- /packages/server/src/modules/authorization/action-log.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { DeepPartial, FindManyOptions, FindOptionsWhere, Repository } from 'typeorm'; 4 | import { ActionLog } from './action-log.entity.js'; 5 | 6 | @Injectable() 7 | export class ActionLogService { 8 | constructor(@InjectRepository(ActionLog) private actionLogRepository: Repository) {} 9 | 10 | async save(log: DeepPartial) { 11 | return await this.actionLogRepository.save(log); 12 | } 13 | 14 | async findOneBy(where: FindOptionsWhere) { 15 | return await this.actionLogRepository.findOneBy(where); 16 | } 17 | 18 | async findAndCount(options: FindManyOptions) { 19 | return await this.actionLogRepository.findAndCount(options); 20 | } 21 | 22 | async delete(where: FindOptionsWhere) { 23 | const result = await this.actionLogRepository.delete(where); 24 | return result.affected > 0; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/server/src/modules/authorization/authorization.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Permission } from './permission.entity.js'; 4 | import { AuthorizationService } from './authorization.service.js'; 5 | import { AuthorizationController } from './authorization.controller.js'; 6 | import { JwtStrategy } from './jwt.strategy.js'; 7 | import { PassportModule } from '@nestjs/passport'; 8 | import { ConfigService } from '@nestjs/config'; 9 | import { JwtModule } from '@nestjs/jwt'; 10 | import { UserModule } from '../user/user.module.js'; 11 | import { ActionLog } from './action-log.entity.js'; 12 | import { ActionLogService } from './action-log.service.js'; 13 | 14 | @Module({ 15 | imports: [ 16 | UserModule, 17 | JwtModule.registerAsync({ 18 | inject: [ConfigService], 19 | useFactory: async (configService: ConfigService) => { 20 | return { 21 | secret: configService.get('APP_SECRET_KEY'), 22 | signOptions: { 23 | expiresIn: '4 Weeks', 24 | }, 25 | }; 26 | }, 27 | }), 28 | PassportModule, 29 | TypeOrmModule.forFeature([Permission, ActionLog]), 30 | ], 31 | providers: [AuthorizationService, ActionLogService, JwtStrategy], 32 | controllers: [AuthorizationController], 33 | exports: [AuthorizationService], 34 | }) 35 | export class AuthorizationModule {} 36 | -------------------------------------------------------------------------------- /packages/server/src/modules/authorization/change-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; 3 | 4 | export class ChangePasswordDto { 5 | @ApiProperty({ 6 | description: '原密码', 7 | required: false, 8 | }) 9 | @IsString() 10 | @IsOptional() 11 | @MinLength(6) 12 | @MaxLength(40) 13 | old?: string; 14 | 15 | @ApiProperty({ 16 | description: '新密码', 17 | }) 18 | @IsString() 19 | @MinLength(6) 20 | @MaxLength(40) 21 | current: string; 22 | } 23 | -------------------------------------------------------------------------------- /packages/server/src/modules/authorization/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsArray, IsEnum, IsString, MaxLength, MinLength } from 'class-validator'; 3 | import { PermissionEnum } from '../../enums/permission.enum.js'; 4 | 5 | export class CreateUserDto { 6 | @ApiProperty({ 7 | description: '用户名', 8 | }) 9 | @IsString() 10 | @MinLength(2) 11 | @MaxLength(40) 12 | username: string; 13 | 14 | @ApiProperty({ 15 | description: '密码', 16 | }) 17 | @IsString() 18 | @MinLength(6) 19 | @MaxLength(40) 20 | password: string; 21 | 22 | @ApiProperty({ 23 | description: '权限列表', 24 | enum: PermissionEnum, 25 | isArray: true, 26 | }) 27 | @IsArray() 28 | @IsEnum(PermissionEnum, { 29 | each: true, 30 | }) 31 | permissionNames: PermissionEnum[]; 32 | } 33 | -------------------------------------------------------------------------------- /packages/server/src/modules/authorization/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ExtractJwt, Strategy } from 'passport-jwt'; 5 | import { UserService } from '../user/user.service.js'; 6 | import { User } from '../user/user.entity.js'; 7 | import { buildException } from '../../utils/build-exception.util.js'; 8 | import { ErrorCodeEnum } from '../../enums/error-code.enum.js'; 9 | 10 | @Injectable() 11 | export class JwtStrategy extends PassportStrategy(Strategy) { 12 | constructor(private userService: UserService, configService: ConfigService) { 13 | super({ 14 | jwtFromRequest: ExtractJwt.fromExtractors([ 15 | ExtractJwt.fromAuthHeaderAsBearerToken(), 16 | ExtractJwt.fromUrlQueryParameter('_token'), 17 | ]), 18 | secretOrKey: configService.get('APP_SECRET_KEY'), 19 | }); 20 | } 21 | 22 | async validate(payload: Pick) { 23 | const user: User = await this.userService.findOneBy({ id: payload.id }); 24 | if (!user || user.ticket !== payload.ticket) { 25 | throw buildException(UnauthorizedException, ErrorCodeEnum.INVALID_TOKEN); 26 | } 27 | 28 | return user; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/server/src/modules/authorization/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class LoginDto { 5 | @ApiProperty({ 6 | description: '用户名', 7 | }) 8 | @IsString() 9 | username: string; 10 | 11 | @ApiProperty({ 12 | description: '密码', 13 | }) 14 | @IsString() 15 | password: string; 16 | } 17 | -------------------------------------------------------------------------------- /packages/server/src/modules/authorization/permission.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsArray, IsEnum } from 'class-validator'; 3 | import { PermissionEnum } from '../../enums/permission.enum.js'; 4 | 5 | export class PermissionDto { 6 | @ApiProperty({ 7 | description: '权限列表', 8 | enum: PermissionEnum, 9 | isArray: true, 10 | }) 11 | @IsArray() 12 | @IsEnum(PermissionEnum, { 13 | each: true, 14 | }) 15 | permissionNames: PermissionEnum[]; 16 | } 17 | -------------------------------------------------------------------------------- /packages/server/src/modules/authorization/permission.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, PrimaryColumn, Relation } from 'typeorm'; 2 | import { User } from '../user/user.entity.js'; 3 | import { Exclude } from 'class-transformer'; 4 | import { PermissionEnum } from '../../enums/index.js'; 5 | 6 | @Entity() 7 | export class Permission { 8 | /** 名称 */ 9 | @PrimaryColumn() 10 | name: PermissionEnum; 11 | 12 | /** 用户 */ 13 | @Exclude() 14 | @ManyToOne(() => User, (user) => user.permissions, { 15 | onDelete: 'CASCADE', 16 | }) 17 | user: Relation; 18 | 19 | @PrimaryColumn() 20 | userId: number; 21 | } 22 | -------------------------------------------------------------------------------- /packages/server/src/modules/authorization/require-permissions.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | import { PermissionEnum } from '../../enums/permission.enum.js'; 3 | 4 | export const REQUIRE_PERMISSIONS_KEY = 'REQUIRE_PERMISSIONS'; 5 | 6 | /** 7 | * 接口权限要求 8 | * @param permissions 要求具有的权限(逻辑或) 9 | */ 10 | export const RequirePermissions = (...permissions: PermissionEnum[]) => 11 | SetMetadata(REQUIRE_PERMISSIONS_KEY, permissions); 12 | -------------------------------------------------------------------------------- /packages/server/src/modules/file/file-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { File } from './file.entity.js'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsDateString, IsInt, IsOptional, IsString } from 'class-validator'; 4 | import { FileSourceEnum } from '../../enums/file-source.enum.js'; 5 | import { Transform } from 'class-transformer'; 6 | import { ApiQueryDto } from '../../common/api.query.dto.js'; 7 | 8 | export class FileQueryDto extends ApiQueryDto { 9 | @ApiProperty({ 10 | description: '查询关键字', 11 | required: false, 12 | }) 13 | @IsOptional() 14 | @IsString() 15 | keyword?: string; 16 | 17 | @ApiProperty({ 18 | description: '文件md5', 19 | required: false, 20 | }) 21 | @IsOptional() 22 | @IsString() 23 | md5?: string; 24 | 25 | @ApiProperty({ 26 | description: '文件来源', 27 | required: false, 28 | }) 29 | @IsOptional() 30 | @IsString() 31 | source?: FileSourceEnum; 32 | 33 | @ApiProperty({ 34 | description: '上传用户id', 35 | required: false, 36 | }) 37 | @Transform(({ value }) => Number(value)) 38 | @IsOptional() 39 | @IsInt() 40 | userId?: number; 41 | 42 | @ApiProperty({ 43 | description: '开始时间', 44 | required: false, 45 | }) 46 | @IsOptional() 47 | @IsDateString() 48 | start?: string; 49 | 50 | @ApiProperty({ 51 | description: '结束时间', 52 | required: false, 53 | }) 54 | @IsOptional() 55 | @IsDateString() 56 | end?: string; 57 | } 58 | -------------------------------------------------------------------------------- /packages/server/src/modules/file/file.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { FileController } from './file.controller.js'; 4 | import { File } from './file.entity.js'; 5 | import { FileService } from './file.service.js'; 6 | 7 | @Module({ 8 | controllers: [FileController], 9 | providers: [FileService], 10 | imports: [TypeOrmModule.forFeature([File])], 11 | exports: [FileService], 12 | }) 13 | export class FileModule {} 14 | -------------------------------------------------------------------------------- /packages/server/src/modules/live/live-audience.ws.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Socket } from 'socket.io'; 3 | import { Reflector } from '@nestjs/core'; 4 | import { ROOM_OWNER_ONLY_KEY } from './room-owner-only.ws.decorator.js'; 5 | import { isDefined } from 'class-validator'; 6 | 7 | @Injectable() 8 | export class LiveAudienceWsGuard implements CanActivate { 9 | constructor(private reflector: Reflector) {} 10 | 11 | async canActivate(context: ExecutionContext) { 12 | const socket: Socket = context.switchToWs().getClient(); 13 | 14 | const roomOwnerOnly = this.reflector.get(ROOM_OWNER_ONLY_KEY, context.getHandler()); 15 | if (roomOwnerOnly) { 16 | return socket.data.user && socket.data.live && socket.data.user.id === socket.data.live.user.id; 17 | } 18 | 19 | return isDefined(socket.data.live); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/server/src/modules/live/live-chat.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, Relation } from 'typeorm'; 2 | import { Exclude, Expose } from 'class-transformer'; 3 | import { Live } from './live.entity.js'; 4 | import { User } from '../user/user.entity.js'; 5 | import { MinaPlayMessage, MinaPlayMessageType, parseMessage } from '../../common/application.message.js'; 6 | 7 | @Entity() 8 | export class LiveChat { 9 | /** id */ 10 | @PrimaryGeneratedColumn('uuid') 11 | id: string; 12 | 13 | /** 直播 */ 14 | @ManyToOne(() => Live, { 15 | onDelete: 'CASCADE', 16 | }) 17 | live: Relation; 18 | 19 | /** 发送用户 */ 20 | @ManyToOne(() => User, { 21 | onDelete: 'CASCADE', 22 | eager: true, 23 | }) 24 | user: Relation; 25 | 26 | /** 消息类型 */ 27 | @Exclude() 28 | @Column() 29 | type: MinaPlayMessageType; 30 | 31 | /** 消息内容 */ 32 | @Exclude() 33 | @Column({ 34 | type: 'text', 35 | }) 36 | content: string; 37 | 38 | /** 消息实体 */ 39 | @Expose() 40 | get message(): MinaPlayMessage { 41 | return parseMessage(JSON.parse(this.content)); 42 | } 43 | 44 | /** 创建时间 */ 45 | @CreateDateColumn() 46 | createAt: Date; 47 | } 48 | -------------------------------------------------------------------------------- /packages/server/src/modules/live/live-chat.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DeepPartial, FindManyOptions, FindOptionsWhere, Repository } from 'typeorm'; 3 | import { LiveChat } from './live-chat.entity.js'; 4 | import { Live } from './live.entity.js'; 5 | import { InjectRepository } from '@nestjs/typeorm'; 6 | 7 | @Injectable() 8 | export class LiveChatService { 9 | constructor(@InjectRepository(LiveChat) private chatRepository: Repository) {} 10 | 11 | async save(chat: DeepPartial) { 12 | return await this.chatRepository.save(chat); 13 | } 14 | 15 | async findAndCount(options: FindManyOptions) { 16 | return await this.chatRepository.findAndCount(options); 17 | } 18 | 19 | async delete(where: FindOptionsWhere) { 20 | const result = await this.chatRepository.delete(where); 21 | return result.affected > 0; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/server/src/modules/live/live-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { Live } from './live.entity.js'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsInt, IsOptional, IsString } from 'class-validator'; 4 | import { Transform } from 'class-transformer'; 5 | import { ApiQueryDto } from '../../common/api.query.dto.js'; 6 | 7 | export class LiveQueryDto extends ApiQueryDto { 8 | @ApiProperty({ 9 | description: '查询关键字', 10 | required: false, 11 | }) 12 | @IsOptional() 13 | @IsString() 14 | keyword?: string; 15 | 16 | @ApiProperty({ 17 | description: '创建用户id', 18 | required: false, 19 | }) 20 | @Transform(({ value }) => Number(value)) 21 | @IsOptional() 22 | @IsInt() 23 | userId?: number; 24 | } 25 | -------------------------------------------------------------------------------- /packages/server/src/modules/live/live-state.insterface.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../user/user.entity.js'; 2 | import { Live } from './live.entity.js'; 3 | 4 | export interface ServerPushMediaStream { 5 | type: 'server-push'; 6 | title?: string; 7 | url: string; 8 | updateAt: Date; 9 | } 10 | 11 | export interface ClientSyncMediaStream { 12 | type: 'client-sync'; 13 | title?: string; 14 | url: string; 15 | status: 'playing' | 'paused'; 16 | position: number; 17 | updateAt: Date; 18 | } 19 | 20 | export interface ThirdPartyLiveStream { 21 | type: 'live-stream'; 22 | title?: string; 23 | url: string; 24 | updateAt: Date; 25 | } 26 | 27 | export type LiveStream = ServerPushMediaStream | ClientSyncMediaStream | ThirdPartyLiveStream; 28 | 29 | export interface LiveState { 30 | live: Live; 31 | users: User[]; 32 | muted: { 33 | chat: number[]; 34 | voice: number[]; 35 | }; 36 | stream: LiveStream; 37 | updateAt: Date; 38 | } 39 | -------------------------------------------------------------------------------- /packages/server/src/modules/live/live-state.ws.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | import { Socket } from 'socket.io'; 3 | 4 | export const WsLiveState = createParamDecorator((data: unknown, ctx: ExecutionContext) => { 5 | const socket: Socket = ctx.switchToWs().getClient(); 6 | return socket.data.state; 7 | }); 8 | -------------------------------------------------------------------------------- /packages/server/src/modules/live/live-state.ws.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; 2 | import { Socket } from 'socket.io'; 3 | import { LiveService } from './live.service.js'; 4 | import { finalize } from 'rxjs'; 5 | 6 | @Injectable() 7 | export class LiveStateWsInterceptor implements NestInterceptor { 8 | constructor(private liveService: LiveService) {} 9 | 10 | async intercept(context: ExecutionContext, next: CallHandler) { 11 | const socket: Socket = context.switchToWs().getClient(); 12 | if (socket.data.live) { 13 | socket.data.state = await this.liveService.createOrGetLiveState(socket.data.live.id); 14 | } 15 | 16 | return next.handle().pipe( 17 | finalize(async () => { 18 | if (socket.data.live && socket.data.state) { 19 | await this.liveService.updateLiveState(socket.data.state); 20 | } 21 | }), 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/server/src/modules/live/live-stream.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Inject, Param, Req, Res } from '@nestjs/common'; 2 | import { ApiOperation, ApiTags } from '@nestjs/swagger'; 3 | import { Request, Response } from 'express'; 4 | import http from 'node:http'; 5 | import { LIVE_MODULE_OPTIONS_TOKEN } from './live.module-definition.js'; 6 | import { LiveModuleOptions } from './live.module.interface.js'; 7 | 8 | @Controller('live') 9 | @ApiTags('live') 10 | export class LiveStreamController { 11 | constructor(@Inject(LIVE_MODULE_OPTIONS_TOKEN) private options: LiveModuleOptions) {} 12 | 13 | @Get(':id/stream.flv') 14 | @ApiOperation({ 15 | description: '获取直播间 FLV 直播流', 16 | }) 17 | async getFlvLiveStreamById(@Param('id') id: string, @Req() req: Request, @Res() res: Response) { 18 | http 19 | .request( 20 | `http://127.0.0.1:${this.options.streamHttpPort}/live/${id}.flv`, 21 | { headers: req.headers }, 22 | (streamRes) => { 23 | streamRes.pipe(res); 24 | }, 25 | ) 26 | .end(); 27 | } 28 | 29 | @Get(':id/stream.m3u8') 30 | @ApiOperation({ 31 | description: '获取直播间 M3U8 直播流', 32 | }) 33 | async getHlsLiveStreamById(@Param('id') id: string, @Req() req: Request, @Res() res: Response) { 34 | http 35 | .request( 36 | `http://127.0.0.1:${this.options.streamHttpPort}/live/${id}.m3u8`, 37 | { headers: req.headers }, 38 | (streamRes) => { 39 | streamRes.pipe(res); 40 | }, 41 | ) 42 | .end(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/server/src/modules/live/live.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; 3 | 4 | export class LiveDto { 5 | @ApiProperty({ 6 | description: '直播间标题', 7 | required: false, 8 | }) 9 | @IsOptional() 10 | @IsString() 11 | @MinLength(1) 12 | @MaxLength(40) 13 | title?: string; 14 | 15 | @ApiProperty({ 16 | description: '直播间密码', 17 | required: false, 18 | }) 19 | @IsOptional() 20 | @IsString() 21 | @MinLength(4) 22 | @MaxLength(16) 23 | password?: string; 24 | 25 | @ApiProperty({ 26 | description: '直播间封面文件id', 27 | required: false, 28 | }) 29 | @IsOptional() 30 | @IsUUID() 31 | posterFileId?: string; 32 | } 33 | -------------------------------------------------------------------------------- /packages/server/src/modules/live/live.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | ManyToOne, 6 | PrimaryGeneratedColumn, 7 | Relation, 8 | UpdateDateColumn, 9 | } from 'typeorm'; 10 | import { File } from '../file/file.entity.js'; 11 | import { Exclude, Expose } from 'class-transformer'; 12 | import { User } from '../user/user.entity.js'; 13 | import { isDefined } from 'class-validator'; 14 | 15 | /** 直播房间 */ 16 | @Entity() 17 | export class Live { 18 | /** id */ 19 | @PrimaryGeneratedColumn('uuid') 20 | id: string; 21 | 22 | /** 标题 */ 23 | @Column({ 24 | nullable: true, 25 | }) 26 | title?: string; 27 | 28 | /** 密码 */ 29 | @Exclude() 30 | @Column({ 31 | nullable: true, 32 | }) 33 | password?: string; 34 | 35 | /** 是否有密码 */ 36 | @Expose() 37 | get hasPassword() { 38 | return isDefined(this.password); 39 | } 40 | 41 | /** 封面图片 */ 42 | @ManyToOne(() => File, { 43 | eager: true, 44 | nullable: true, 45 | }) 46 | poster?: Relation; 47 | 48 | /** 创建用户 */ 49 | @ManyToOne(() => User, { 50 | onDelete: 'SET NULL', 51 | eager: true, 52 | nullable: true, 53 | }) 54 | user?: Relation; 55 | 56 | /** 创建时间 */ 57 | @CreateDateColumn() 58 | createAt: Date; 59 | 60 | /** 修改时间 */ 61 | @UpdateDateColumn() 62 | updateAt: Date; 63 | } 64 | -------------------------------------------------------------------------------- /packages/server/src/modules/live/live.module-definition.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurableModuleBuilder } from '@nestjs/common'; 2 | import { LiveModuleOptions } from './live.module.interface.js'; 3 | 4 | export const { ConfigurableModuleClass: LiveConfigurableModule, MODULE_OPTIONS_TOKEN: LIVE_MODULE_OPTIONS_TOKEN } = 5 | new ConfigurableModuleBuilder({ moduleName: 'Live' }) 6 | .setExtras({ isGlobal: true }, (definition, extras) => ({ 7 | ...definition, 8 | global: extras.isGlobal, 9 | })) 10 | .build(); 11 | -------------------------------------------------------------------------------- /packages/server/src/modules/live/live.module.interface.ts: -------------------------------------------------------------------------------- 1 | export interface LiveModuleOptions { 2 | mediasoupAnnouncedAddress: string; 3 | mediasoupRtcMinPort: number; 4 | mediasoupRtcMaxPort: number; 5 | mediasoupWorkerNum: number; 6 | mediasoupAudioClockRate?: number; 7 | mediasoupAudioChannel?: number; 8 | mediasoupMaxIncomeBitrate?: number; 9 | 10 | streamRtmpPort?: number; 11 | streamHttpPort?: number; 12 | streamChunkSize?: number; 13 | streamFfmpegPath: string; 14 | streamPublishKey: string; 15 | } 16 | -------------------------------------------------------------------------------- /packages/server/src/modules/live/live.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Live } from './live.entity.js'; 4 | import { LiveController } from './live.controller.js'; 5 | import { LiveService } from './live.service.js'; 6 | import { LiveConfigurableModule } from './live.module-definition.js'; 7 | import { AuthorizationModule } from '../authorization/authorization.module.js'; 8 | import { UserModule } from '../user/user.module.js'; 9 | import { LiveGateway } from './live.gateway.js'; 10 | import { LiveChatService } from './live-chat.service.js'; 11 | import { LiveChat } from './live-chat.entity.js'; 12 | import { LiveVoiceService } from './live-voice.service.js'; 13 | import { LiveStreamService } from './live-stream.service.js'; 14 | import { FileModule } from '../file/file.module.js'; 15 | import { LiveStreamController } from './live-stream.controller.js'; 16 | 17 | @Module({ 18 | imports: [TypeOrmModule.forFeature([Live, LiveChat]), AuthorizationModule, UserModule, FileModule], 19 | providers: [LiveService, LiveVoiceService, LiveStreamService, LiveChatService, LiveGateway], 20 | controllers: [LiveStreamController, LiveController], 21 | exports: [LiveService, LiveVoiceService, LiveStreamService, LiveChatService], 22 | }) 23 | export class LiveModule extends LiveConfigurableModule { 24 | declare static register: typeof LiveConfigurableModule.register; 25 | declare static registerAsync: typeof LiveConfigurableModule.registerAsync; 26 | } 27 | -------------------------------------------------------------------------------- /packages/server/src/modules/live/room-owner-only.ws.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const ROOM_OWNER_ONLY_KEY = 'ROOM_OWNER_ONLY'; 4 | 5 | export const RoomOwnerOnly = () => SetMetadata(ROOM_OWNER_ONLY_KEY, true); 6 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/episode/episode-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { Episode } from './episode.entity.js'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsDateString, IsInt, IsOptional, IsString } from 'class-validator'; 4 | import { Transform } from 'class-transformer'; 5 | import { ApiQueryDto } from '../../../common/api.query.dto.js'; 6 | 7 | export class EpisodeQueryDto extends ApiQueryDto { 8 | @ApiProperty({ 9 | description: '查询关键字', 10 | required: false, 11 | }) 12 | @IsOptional() 13 | @IsString() 14 | keyword?: string; 15 | 16 | @ApiProperty({ 17 | description: '剧集ID', 18 | required: false, 19 | }) 20 | @Transform(({ value }) => Number(value)) 21 | @IsOptional() 22 | @IsInt() 23 | seriesId?: number; 24 | 25 | @ApiProperty({ 26 | description: '开始时间', 27 | required: false, 28 | }) 29 | @IsOptional() 30 | @IsDateString() 31 | start?: string; 32 | 33 | @ApiProperty({ 34 | description: '结束时间', 35 | required: false, 36 | }) 37 | @IsOptional() 38 | @IsDateString() 39 | end?: string; 40 | } 41 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/episode/episode-update-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiQueryDto } from '../../../common/api.query.dto.js'; 2 | import { Episode } from './episode.entity.js'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | import { IsDateString, IsOptional } from 'class-validator'; 5 | 6 | export class EpisodeUpdateQueryDto extends ApiQueryDto { 7 | @ApiProperty({ 8 | description: '开始时间', 9 | required: false, 10 | }) 11 | @IsOptional() 12 | @IsDateString() 13 | start?: string; 14 | 15 | @ApiProperty({ 16 | description: '结束时间', 17 | required: false, 18 | }) 19 | @IsOptional() 20 | @IsDateString() 21 | end?: string; 22 | } 23 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/episode/episode.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsDateString, IsInt, IsNumberString, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; 3 | 4 | export class EpisodeDto { 5 | @ApiProperty({ 6 | description: '单集标题', 7 | required: false, 8 | }) 9 | @IsString() 10 | @IsOptional() 11 | @MaxLength(120) 12 | title?: string; 13 | 14 | @ApiProperty({ 15 | description: '单集编号', 16 | required: false, 17 | }) 18 | @IsNumberString() 19 | @IsOptional() 20 | @MaxLength(20) 21 | no?: string; 22 | 23 | @ApiProperty({ 24 | description: '发布时间', 25 | required: false, 26 | }) 27 | @IsDateString() 28 | @IsOptional() 29 | pubAt?: string; 30 | 31 | @ApiProperty({ 32 | description: '所属剧集', 33 | required: false, 34 | }) 35 | @IsInt() 36 | @IsOptional() 37 | seriesId?: number; 38 | 39 | @ApiProperty({ 40 | description: '媒体id', 41 | required: false, 42 | }) 43 | @IsUUID() 44 | @IsOptional() 45 | mediaId?: string; 46 | } 47 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/episode/episode.entity.subscriber.ts: -------------------------------------------------------------------------------- 1 | import { DataSource, EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm'; 2 | import { Episode } from './episode.entity.js'; 3 | import { NotificationService } from '../../notification/notification.service.js'; 4 | import { NotificationEventEnum } from '../../../enums/index.js'; 5 | 6 | @EventSubscriber() 7 | export class EpisodeEntitySubscriber implements EntitySubscriberInterface { 8 | constructor(dataSource: DataSource, private notificationService: NotificationService) { 9 | dataSource.subscribers.push(this); 10 | } 11 | 12 | listenTo() { 13 | return Episode; 14 | } 15 | 16 | async afterInsert(event: InsertEvent) { 17 | await this.notificationService.notify(NotificationEventEnum.NEW_EPISODE, { 18 | episode: await event.manager.getRepository(Episode).findOneBy({ id: event.entity.id }), 19 | time: new Date(), 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/episode/episode.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | ManyToOne, 6 | PrimaryGeneratedColumn, 7 | Relation, 8 | UpdateDateColumn, 9 | } from 'typeorm'; 10 | import { Series } from '../series/series.entity.js'; 11 | import { Media } from '../media.entity.js'; 12 | 13 | /** 单集 */ 14 | @Entity() 15 | export class Episode { 16 | /** id */ 17 | @PrimaryGeneratedColumn('increment') 18 | id: number; 19 | 20 | /** 本集标题 */ 21 | @Column({ 22 | nullable: true, 23 | }) 24 | title?: string; 25 | 26 | /** 本集集数 */ 27 | @Column({ 28 | nullable: true, 29 | }) 30 | no?: string; 31 | 32 | /** 媒体 */ 33 | @ManyToOne(() => Media, { 34 | onDelete: 'CASCADE', 35 | eager: true, 36 | }) 37 | media: Relation; 38 | 39 | /** 剧集 */ 40 | @ManyToOne(() => Series, (series) => series.episodes, { 41 | onDelete: 'CASCADE', 42 | eager: true, 43 | }) 44 | series: Relation; 45 | 46 | /** 发布时间 */ 47 | @Column({ 48 | type: 'datetime', 49 | nullable: true, 50 | }) 51 | pubAt: Date; 52 | 53 | /** 创建时间 */ 54 | @CreateDateColumn() 55 | createAt: Date; 56 | 57 | /** 更新时间 */ 58 | @UpdateDateColumn() 59 | updateAt: Date; 60 | } 61 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/media-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { Media } from './media.entity.js'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsDateString, IsOptional, IsString } from 'class-validator'; 4 | import { ApiQueryDto } from '../../common/api.query.dto.js'; 5 | 6 | export class MediaQueryDto extends ApiQueryDto { 7 | @ApiProperty({ 8 | description: '查询关键字', 9 | required: false, 10 | }) 11 | @IsOptional() 12 | @IsString() 13 | keyword?: string; 14 | 15 | @ApiProperty({ 16 | description: '开始时间', 17 | required: false, 18 | }) 19 | @IsOptional() 20 | @IsDateString() 21 | start?: string; 22 | 23 | @ApiProperty({ 24 | description: '结束时间', 25 | required: false, 26 | }) 27 | @IsOptional() 28 | @IsDateString() 29 | end?: string; 30 | } 31 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/media.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsBoolean, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; 3 | 4 | export class MediaDto { 5 | @ApiProperty({ 6 | description: '剧集名称', 7 | required: false, 8 | }) 9 | @IsOptional() 10 | @IsString() 11 | @MinLength(1) 12 | @MaxLength(100) 13 | name?: string; 14 | 15 | @ApiProperty({ 16 | description: '剧集描述', 17 | required: false, 18 | }) 19 | @IsOptional() 20 | @IsString() 21 | @MaxLength(2048) 22 | description?: string; 23 | 24 | @ApiProperty({ 25 | description: '是否公开', 26 | required: false, 27 | }) 28 | @IsOptional() 29 | @IsBoolean() 30 | isPublic?: boolean; 31 | 32 | @ApiProperty({ 33 | description: '媒体文件id', 34 | required: false, 35 | }) 36 | @IsOptional() 37 | @IsUUID() 38 | fileId?: string; 39 | 40 | @ApiProperty({ 41 | description: '海报文件id', 42 | required: false, 43 | }) 44 | @IsOptional() 45 | @IsUUID() 46 | posterFileId?: string; 47 | } 48 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/media.entity.subscriber.ts: -------------------------------------------------------------------------------- 1 | import { DataSource, EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm'; 2 | import { Media } from './media.entity.js'; 3 | import { NotificationService } from '../notification/notification.service.js'; 4 | import { NotificationEventEnum } from '../../enums/index.js'; 5 | 6 | @EventSubscriber() 7 | export class MediaEntitySubscriber implements EntitySubscriberInterface { 8 | constructor(dataSource: DataSource, private notificationService: NotificationService) { 9 | dataSource.subscribers.push(this); 10 | } 11 | 12 | listenTo() { 13 | return Media; 14 | } 15 | 16 | async afterInsert(event: InsertEvent) { 17 | await this.notificationService.notify(NotificationEventEnum.NEW_MEDIA, { 18 | media: await event.manager.getRepository(Media).findOneBy({ id: event.entity.id }), 19 | time: new Date(), 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/media.module-definition.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurableModuleBuilder } from '@nestjs/common'; 2 | import { MediaModuleOptions } from './media.module.interface.js'; 3 | 4 | export const { ConfigurableModuleClass: MediaConfigurableModule, MODULE_OPTIONS_TOKEN: MEDIA_MODULE_OPTIONS_TOKEN } = 5 | new ConfigurableModuleBuilder({ moduleName: 'Media' }) 6 | .setExtras({ isGlobal: true }, (definition, extras) => ({ 7 | ...definition, 8 | global: extras.isGlobal, 9 | })) 10 | .build(); 11 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/media.module.interface.ts: -------------------------------------------------------------------------------- 1 | export interface MediaModuleOptions { 2 | ffmpegPath: string; 3 | ffprobePath: string; 4 | } 5 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/media.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DeepPartial, FindManyOptions, FindOptionsWhere, In, Repository } from 'typeorm'; 3 | import { Media } from './media.entity.js'; 4 | import { InjectRepository } from '@nestjs/typeorm'; 5 | import { FileService } from '../file/file.service.js'; 6 | import { File } from '../file/file.entity.js'; 7 | import { isDefined } from 'class-validator'; 8 | 9 | @Injectable() 10 | export class MediaService { 11 | constructor(@InjectRepository(Media) private mediaRepository: Repository, private fileService: FileService) {} 12 | 13 | async save(media: DeepPartial) { 14 | return await this.mediaRepository.save(media); 15 | } 16 | 17 | async findOneBy(where: FindOptionsWhere) { 18 | return await this.mediaRepository.findOneBy(where); 19 | } 20 | 21 | async findAndCount(options?: FindManyOptions) { 22 | return await this.mediaRepository.findAndCount(options); 23 | } 24 | 25 | async delete(where: FindOptionsWhere) { 26 | const medias = await this.mediaRepository.find({ where }); 27 | for (const media of medias) { 28 | const ids = [] 29 | .concat(media.file) 30 | .concat(media.poster) 31 | .concat(media.attachments) 32 | .filter((v) => isDefined(v)) 33 | .map((v: File) => v.id); 34 | await this.fileService.delete({ 35 | id: In(ids), 36 | }); 37 | } 38 | 39 | const result = await this.mediaRepository.delete(where); 40 | return result.affected > 0; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/series/series-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { Series } from './series.entity.js'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsBoolean, IsDateString, IsInt, IsOptional, IsString } from 'class-validator'; 4 | import { Transform } from 'class-transformer'; 5 | import { ApiQueryDto } from '../../../common/api.query.dto.js'; 6 | 7 | export class SeriesQueryDto extends ApiQueryDto { 8 | @ApiProperty({ 9 | description: '查询关键字', 10 | required: false, 11 | }) 12 | @IsOptional() 13 | @IsString() 14 | keyword?: string; 15 | 16 | @ApiProperty({ 17 | description: '剧集名称', 18 | required: false, 19 | }) 20 | @IsOptional() 21 | @IsString() 22 | name?: string; 23 | 24 | @ApiProperty({ 25 | description: '剧集季度', 26 | required: false, 27 | }) 28 | @IsOptional() 29 | @IsString() 30 | season?: string; 31 | 32 | @ApiProperty({ 33 | description: '是否完结', 34 | required: false, 35 | enum: [0, 1], 36 | }) 37 | @Transform(({ value }) => Boolean(Number(value))) 38 | @IsOptional() 39 | @IsBoolean() 40 | finished?: boolean; 41 | 42 | @ApiProperty({ 43 | description: '创建用户id', 44 | required: false, 45 | }) 46 | @Transform(({ value }) => Number(value)) 47 | @IsOptional() 48 | @IsInt() 49 | userId?: number; 50 | 51 | @ApiProperty({ 52 | description: '剧集标签', 53 | required: false, 54 | }) 55 | @IsOptional() 56 | @IsString() 57 | tag?: string; 58 | 59 | @ApiProperty({ 60 | description: '开始时间', 61 | required: false, 62 | }) 63 | @IsOptional() 64 | @IsDateString() 65 | start?: string; 66 | 67 | @ApiProperty({ 68 | description: '结束时间', 69 | required: false, 70 | }) 71 | @IsOptional() 72 | @IsDateString() 73 | end?: string; 74 | } 75 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/series/series-subscribe.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsBoolean, IsOptional } from 'class-validator'; 3 | 4 | export class SeriesSubscribeDto { 5 | @ApiProperty({ 6 | description: '是否通知', 7 | required: false, 8 | }) 9 | @IsOptional() 10 | @IsBoolean() 11 | notify?: boolean; 12 | } 13 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/series/series-subscribe.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryColumn, Relation } from 'typeorm'; 2 | import { User } from '../../user/user.entity.js'; 3 | import { Series } from './series.entity.js'; 4 | import { Exclude } from 'class-transformer'; 5 | 6 | /** 剧集订阅 */ 7 | @Entity() 8 | export class SeriesSubscribe { 9 | /** 用户 */ 10 | @ManyToOne(() => User, { 11 | onDelete: 'CASCADE', 12 | }) 13 | user: Relation; 14 | 15 | @Exclude() 16 | @PrimaryColumn() 17 | userId: number; 18 | 19 | /** 剧集 */ 20 | @ManyToOne(() => Series, { 21 | onDelete: 'CASCADE', 22 | }) 23 | series: Relation; 24 | 25 | @Exclude() 26 | @PrimaryColumn() 27 | seriesId: number; 28 | 29 | /** 启用通知 */ 30 | @Column({ 31 | nullable: true, 32 | default: true, 33 | }) 34 | notify: boolean; 35 | 36 | /** 创建时间 */ 37 | @CreateDateColumn() 38 | createAt: Date; 39 | } 40 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/series/series-subscribe.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { DeepPartial, FindManyOptions, FindOptionsWhere, Repository } from 'typeorm'; 4 | import { SeriesSubscribe } from './series-subscribe.entity.js'; 5 | 6 | @Injectable() 7 | export class SeriesSubscribeService { 8 | constructor(@InjectRepository(SeriesSubscribe) private seriesSubscribeRepository: Repository) {} 9 | 10 | async save(subscribe: DeepPartial) { 11 | return await this.seriesSubscribeRepository.save(subscribe); 12 | } 13 | 14 | async findOneBy(where: FindOptionsWhere) { 15 | return await this.seriesSubscribeRepository.findOneBy(where); 16 | } 17 | 18 | async findAndCount(options?: FindManyOptions) { 19 | return await this.seriesSubscribeRepository.findAndCount(options); 20 | } 21 | 22 | async delete(where: FindOptionsWhere) { 23 | const result = await this.seriesSubscribeRepository.delete(where); 24 | return result.affected > 0; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/series/series-tag-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { SeriesTag } from './series-tag.entity.js'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsOptional, IsString } from 'class-validator'; 4 | import { ApiQueryDto } from '../../../common/api.query.dto.js'; 5 | 6 | export class SeriesTagQueryDto extends ApiQueryDto { 7 | @ApiProperty({ 8 | description: '查询关键字', 9 | required: false, 10 | }) 11 | @IsOptional() 12 | @IsString() 13 | keyword?: string; 14 | } 15 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/series/series-tag.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; 3 | 4 | export class SeriesTagDto { 5 | @ApiProperty({ 6 | description: '剧集标签名称', 7 | required: false, 8 | }) 9 | @IsOptional() 10 | @IsString() 11 | @MinLength(1) 12 | @MaxLength(40) 13 | name?: string; 14 | } 15 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/series/series-tag.entity.ts: -------------------------------------------------------------------------------- 1 | import { CreateDateColumn, Entity, ManyToMany, PrimaryColumn, Relation, UpdateDateColumn } from 'typeorm'; 2 | import { Series } from './series.entity.js'; 3 | 4 | /** 剧集标签 */ 5 | @Entity() 6 | export class SeriesTag { 7 | /** 剧集标签名称 */ 8 | @PrimaryColumn() 9 | name: string; 10 | 11 | /** 剧集 */ 12 | @ManyToMany(() => Series, (series) => series.tags, { 13 | onDelete: 'CASCADE', 14 | }) 15 | series: Relation>; 16 | 17 | /** 创建时间 */ 18 | @CreateDateColumn() 19 | createAt: Date; 20 | 21 | /** 修改时间 */ 22 | @UpdateDateColumn() 23 | updateAt: Date; 24 | } 25 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/series/series-tag.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { SeriesTag } from './series-tag.entity.js'; 4 | import { DeepPartial, FindManyOptions, FindOptionsWhere, Repository } from 'typeorm'; 5 | 6 | @Injectable() 7 | export class SeriesTagService { 8 | constructor(@InjectRepository(SeriesTag) private seriesTagRepository: Repository) {} 9 | 10 | save(tag: DeepPartial): Promise; 11 | save(tag: DeepPartial[]): Promise; 12 | async save(tag: any) { 13 | return await this.seriesTagRepository.save(tag); 14 | } 15 | 16 | async findOneBy(where: FindOptionsWhere) { 17 | return await this.seriesTagRepository.findOneBy(where); 18 | } 19 | 20 | async findAndCount(options?: FindManyOptions) { 21 | return await this.seriesTagRepository.findAndCount(options); 22 | } 23 | 24 | async delete(where: FindOptionsWhere) { 25 | const result = await this.seriesTagRepository.delete(where); 26 | return result.affected > 0; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/series/series.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { 3 | IsArray, 4 | IsBoolean, 5 | IsDateString, 6 | IsNumber, 7 | IsNumberString, 8 | IsOptional, 9 | IsString, 10 | IsUUID, 11 | MaxLength, 12 | MinLength, 13 | } from 'class-validator'; 14 | 15 | export class SeriesDto { 16 | @ApiProperty({ 17 | description: '剧集名称', 18 | required: false, 19 | }) 20 | @IsOptional() 21 | @IsString() 22 | @MinLength(1) 23 | @MaxLength(100) 24 | name?: string; 25 | 26 | @ApiProperty({ 27 | description: '是否完结', 28 | required: false, 29 | }) 30 | @IsOptional() 31 | @IsBoolean() 32 | finished?: boolean; 33 | 34 | @ApiProperty({ 35 | description: '季度', 36 | required: false, 37 | }) 38 | @IsOptional() 39 | @IsNumberString() 40 | @MaxLength(20) 41 | season?: string; 42 | 43 | @ApiProperty({ 44 | description: '发布时间', 45 | required: false, 46 | }) 47 | @IsDateString() 48 | @IsOptional() 49 | pubAt?: string; 50 | 51 | @ApiProperty({ 52 | description: '完整单集数量', 53 | required: false, 54 | }) 55 | @IsOptional() 56 | @IsNumber() 57 | count?: number; 58 | 59 | @ApiProperty({ 60 | description: '剧集描述', 61 | required: false, 62 | }) 63 | @IsOptional() 64 | @IsString() 65 | @MaxLength(2048) 66 | description?: string; 67 | 68 | @ApiProperty({ 69 | description: '海报文件id', 70 | required: false, 71 | }) 72 | @IsOptional() 73 | @IsUUID() 74 | posterFileId?: string; 75 | 76 | @ApiProperty({ 77 | description: '剧集标签', 78 | required: false, 79 | type: [String], 80 | }) 81 | @IsOptional() 82 | @IsArray() 83 | @IsString({ 84 | each: true, 85 | }) 86 | tags?: string[]; 87 | } 88 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/series/series.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Series } from './series.entity.js'; 4 | import { DeepPartial, FindManyOptions, FindOptionsWhere, In, Repository } from 'typeorm'; 5 | import { File } from '../../file/file.entity.js'; 6 | import { FileService } from '../../file/file.service.js'; 7 | import { isDefined } from 'class-validator'; 8 | 9 | @Injectable() 10 | export class SeriesService { 11 | constructor( 12 | @InjectRepository(Series) private seriesRepository: Repository, 13 | private fileService: FileService, 14 | ) {} 15 | 16 | async save(series: DeepPartial) { 17 | return await this.seriesRepository.save(series); 18 | } 19 | 20 | async findOneBy(where: FindOptionsWhere) { 21 | return await this.seriesRepository.findOneBy(where); 22 | } 23 | 24 | async findAndCount(options?: FindManyOptions) { 25 | return await this.seriesRepository.findAndCount(options); 26 | } 27 | 28 | async delete(where: FindOptionsWhere) { 29 | const series = await this.seriesRepository.find({ where }); 30 | for (const item of series) { 31 | const ids = [] 32 | .concat(item.poster) 33 | .filter((v) => isDefined(v)) 34 | .map((v: File) => v.id); 35 | await this.fileService.delete({ 36 | id: In(ids), 37 | }); 38 | } 39 | 40 | const result = await this.seriesRepository.delete(where); 41 | return result.affected > 0; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/view-history/view-history.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsInt, IsOptional } from 'class-validator'; 3 | 4 | export class ViewHistoryDto { 5 | @ApiProperty({ 6 | description: '进度', 7 | required: false, 8 | }) 9 | @IsOptional() 10 | @IsInt() 11 | progress?: number; 12 | 13 | @ApiProperty({ 14 | description: '单集id', 15 | required: false, 16 | }) 17 | @IsOptional() 18 | @IsInt() 19 | episodeId?: number; 20 | } 21 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/view-history/view-history.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | ManyToOne, 6 | PrimaryGeneratedColumn, 7 | Relation, 8 | UpdateDateColumn, 9 | } from 'typeorm'; 10 | import { Media } from '../media.entity.js'; 11 | import { User } from '../../user/user.entity.js'; 12 | import { Episode } from '../episode/episode.entity.js'; 13 | 14 | @Entity() 15 | export class ViewHistory { 16 | /** id */ 17 | @PrimaryGeneratedColumn('uuid') 18 | id: string; 19 | 20 | /** 媒体 */ 21 | @ManyToOne(() => Media, { 22 | onDelete: 'CASCADE', 23 | eager: true, 24 | }) 25 | media: Relation; 26 | 27 | /** 单集 */ 28 | @ManyToOne(() => Episode, { 29 | onDelete: 'SET NULL', 30 | nullable: true, 31 | }) 32 | episode?: Relation; 33 | 34 | /** 单集 ID */ 35 | @Column({ 36 | nullable: true, 37 | }) 38 | episodeId?: number; 39 | 40 | /** 用户 */ 41 | @ManyToOne(() => User, { 42 | onDelete: 'CASCADE', 43 | }) 44 | user: Relation; 45 | 46 | /** 进度 */ 47 | @Column({ 48 | nullable: true, 49 | }) 50 | progress?: number; 51 | 52 | /** 创建时间 */ 53 | @CreateDateColumn() 54 | createAt: Date; 55 | 56 | /** 更新时间 */ 57 | @UpdateDateColumn() 58 | updateAt: Date; 59 | } 60 | -------------------------------------------------------------------------------- /packages/server/src/modules/media/view-history/view-history.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { DeepPartial, FindManyOptions, FindOptionsWhere, Repository } from 'typeorm'; 4 | import { ViewHistory } from './view-history.entity.js'; 5 | 6 | @Injectable() 7 | export class ViewHistoryService { 8 | constructor(@InjectRepository(ViewHistory) private viewHistoryRepository: Repository) {} 9 | 10 | async save(history: DeepPartial) { 11 | return await this.viewHistoryRepository.save(history); 12 | } 13 | 14 | async findOneBy(where: FindOptionsWhere) { 15 | return await this.viewHistoryRepository.findOneBy(where); 16 | } 17 | 18 | async findAndCount(options?: FindManyOptions) { 19 | return await this.viewHistoryRepository.findAndCount(options); 20 | } 21 | 22 | async delete(where: FindOptionsWhere) { 23 | const result = await this.viewHistoryRepository.delete(where); 24 | return result.affected > 0; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/adapters/email/email.config.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail } from 'class-validator'; 2 | 3 | export class EmailConfig { 4 | @IsEmail() 5 | address: string; 6 | } 7 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/adapters/email/templates/new-episode.handlebars: -------------------------------------------------------------------------------- 1 |

Hello!

2 |
3 |

4 | Welcome to MinaPlay. Series 5 | 6 | {{episode.series.name}} 7 | {{#if episode.series.season}} 8 | season {{episode.series.season}} 9 | {{/if}} 10 | 11 | updated with a new episode 12 | 13 | {{#if episode.no}} 14 | {{episode.no}} - 15 | {{/if}} 16 | {{episode.title}} 17 | 18 | . 19 |

20 |
21 |

MinaPlay

22 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/adapters/email/templates/new-media.handlebars: -------------------------------------------------------------------------------- 1 |

Hello!

2 |
3 |

4 | Welcome to MinaPlay. A new media {{media.name}} updated. 5 |

6 |
7 |

MinaPlay

8 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/adapters/email/templates/verify-code.handlebars: -------------------------------------------------------------------------------- 1 |

Hello!

2 |
3 |

Welcome to MinaPlay. Your email verification code is {{code}}, valid for 30 minutes

4 |

Please ignore this email if not operated by yourself.

5 |
6 |

Thanks

7 |

MinaPlay

8 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/adapters/server-chan/server-chan.config.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class ServerChanConfig { 4 | @IsString() 5 | token: string; 6 | } 7 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/adapters/server-chan/templates/new-episode.handlebars: -------------------------------------------------------------------------------- 1 | ### MinaPlay Notification 2 | 3 | Hello! Welcome to MinaPlay. Series __{{episode.series.name}}__ {{#if episode.series.season}}season {{episode.series.season}}{{/if}} 4 | updated with a new episode 5 | __{{#if episode.no}}{{episode.no}} - {{/if}}{{episode.title}}__ 6 | . 7 | 8 | _Powered by MinaPlay_ 9 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/adapters/server-chan/templates/new-media.handlebars: -------------------------------------------------------------------------------- 1 | ### MinaPlay Notification 2 | 3 | Hello! Welcome to MinaPlay. A new media __{{media.name}}__ updated. 4 | 5 | _Powered by MinaPlay_ 6 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/adapters/telegram/telegram.config.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class TelegramConfig { 4 | @IsString() 5 | token: string; 6 | 7 | @IsString() 8 | chatId: string; 9 | } 10 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/adapters/telegram/templates/new-episode.handlebars: -------------------------------------------------------------------------------- 1 | Series '{{episode.series.name}}' {{#if episode.series.season}}season {{episode.series.season}}{{/if}} updated with a new episode '{{#if episode.no}}{{episode.no}} - {{/if}}{{episode.title}}'. 2 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/adapters/telegram/templates/new-media.handlebars: -------------------------------------------------------------------------------- 1 | A new media '{{media.name}}' updated. 2 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/adapters/ws/ws.config.ts: -------------------------------------------------------------------------------- 1 | export class WsConfig {} 2 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/notification-event.interface.ts: -------------------------------------------------------------------------------- 1 | import { Episode } from '../media/episode/episode.entity.js'; 2 | import { Media } from '../media/media.entity.js'; 3 | import { NotificationEventEnum } from '../../enums/notification-event.enum.js'; 4 | 5 | export type NotificationEventMap = { 6 | [NotificationEventEnum.NEW_EPISODE]: { 7 | episode: Episode; 8 | time: Date; 9 | }; 10 | [NotificationEventEnum.NEW_MEDIA]: { 11 | media: Media; 12 | time: Date; 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/notification-meta.dto.ts: -------------------------------------------------------------------------------- 1 | import { NotificationEventEnum } from '../../enums/index.js'; 2 | import { IsBoolean, IsEnum, IsOptional } from 'class-validator'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | 5 | export class NotificationMetaDto { 6 | @ApiProperty({ 7 | description: '是否启用', 8 | required: false, 9 | }) 10 | @IsBoolean() 11 | @IsOptional() 12 | enabled: boolean; 13 | 14 | @ApiProperty({ 15 | description: '订阅事件', 16 | required: false, 17 | }) 18 | @IsEnum(NotificationEventEnum, { 19 | each: true, 20 | }) 21 | @IsOptional() 22 | subscribes: NotificationEventEnum[]; 23 | } 24 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/notification-meta.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { NotificationMeta } from './notification-meta.entity.js'; 4 | import { DeepPartial, FindManyOptions, FindOptionsWhere, Repository } from 'typeorm'; 5 | 6 | @Injectable() 7 | export class NotificationMetaService { 8 | constructor(@InjectRepository(NotificationMeta) private notificationMetaRepository: Repository) {} 9 | 10 | async save(meta: DeepPartial) { 11 | return await this.notificationMetaRepository.save(meta); 12 | } 13 | 14 | async findOneBy(where: FindOptionsWhere) { 15 | return await this.notificationMetaRepository.findOneBy(where); 16 | } 17 | 18 | async findAndCount(options?: FindManyOptions) { 19 | return await this.notificationMetaRepository.findAndCount(options); 20 | } 21 | 22 | async delete(where: FindOptionsWhere) { 23 | const result = await this.notificationMetaRepository.delete(where); 24 | return result.affected > 0; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/notification-service-adapter.interface.ts: -------------------------------------------------------------------------------- 1 | import { NotificationEventMap } from './notification-event.interface.js'; 2 | import { NotificationEventEnum, NotificationServiceEnum } from '../../enums/index.js'; 3 | import { Type } from '@nestjs/common'; 4 | import { User } from '../user/user.entity.js'; 5 | 6 | export interface NotificationServiceAdapter { 7 | adapterServiceType: NotificationServiceEnum; 8 | adapterConfigType: Type; 9 | 10 | isEnabled(): boolean; 11 | 12 | init(): any; 13 | 14 | notify(event: T, data: NotificationEventMap[T], userId: number, config: Config): any; 15 | 16 | test(user: User, config: Config): any; 17 | } 18 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/notification.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsObject } from 'class-validator'; 2 | import { NotificationServiceEnum } from '../../enums/index.js'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | 5 | export class NotificationDto { 6 | @ApiProperty({ 7 | description: '通知服务', 8 | enum: NotificationServiceEnum, 9 | }) 10 | @IsEnum(NotificationServiceEnum) 11 | service: NotificationServiceEnum; 12 | 13 | @ApiProperty({ 14 | description: '通知服务配置', 15 | }) 16 | @IsObject() 17 | config: object; 18 | } 19 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/notification.module-definition.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurableModuleBuilder } from '@nestjs/common'; 2 | import { NotificationModuleOptions } from './notification.module.interface.js'; 3 | 4 | export const { 5 | ConfigurableModuleClass: NotificationConfigurableModule, 6 | MODULE_OPTIONS_TOKEN: NOTIFICATION_MODULE_OPTIONS_TOKEN, 7 | } = new ConfigurableModuleBuilder({ moduleName: 'Notification' }) 8 | .setExtras({ isGlobal: true }, (definition, extras) => ({ 9 | ...definition, 10 | global: extras.isGlobal, 11 | })) 12 | .build(); 13 | -------------------------------------------------------------------------------- /packages/server/src/modules/notification/notification.module.interface.ts: -------------------------------------------------------------------------------- 1 | export interface NotificationModuleOptions { 2 | // ws 3 | wsEnabled: boolean; 4 | 5 | // email 6 | emailEnabled: boolean; 7 | emailSmtpHost?: string; 8 | emailSmtpPort?: number; 9 | emailSmtpSecure?: boolean; 10 | emailSmtpUser?: string; 11 | emailSmtpPassword?: string; 12 | emailOrigin?: string; 13 | emailSubject?: string; 14 | 15 | // server-chan 16 | serverChanEnabled: boolean; 17 | 18 | // telegram 19 | telegramEnabled: boolean; 20 | 21 | appEnv: 'dev' | 'prod'; 22 | httpProxy?: string; 23 | } 24 | -------------------------------------------------------------------------------- /packages/server/src/modules/plugin/builtin/help/help.plugin.ts: -------------------------------------------------------------------------------- 1 | import { MinaPlayPlugin } from '../../plugin.decorator.js'; 2 | import { MINAPLAY_VERSION } from '../../../../constants.js'; 3 | import { HelpCommand } from './help.command.js'; 4 | 5 | @MinaPlayPlugin({ 6 | id: 'help', 7 | version: MINAPLAY_VERSION, 8 | description: 'Show helps in MinaPlay plugin console', 9 | author: 'MinaPlay', 10 | repo: 'https://github.com/nepsyn/minaplay', 11 | license: 'AGPL-3.0', 12 | imports: [], 13 | providers: [HelpCommand], 14 | }) 15 | export default class HelpPlugin {} 16 | -------------------------------------------------------------------------------- /packages/server/src/modules/plugin/builtin/plugin-manager/plugin-manager.plugin.ts: -------------------------------------------------------------------------------- 1 | import { MinaPlayPlugin } from '../../plugin.decorator.js'; 2 | import { MINAPLAY_VERSION } from '../../../../constants.js'; 3 | import { PluginManagerCommand } from './plugin-manager.command.js'; 4 | 5 | @MinaPlayPlugin({ 6 | id: 'plugin-manager', 7 | version: MINAPLAY_VERSION, 8 | description: 'Manage plugins in MinaPlay', 9 | author: 'MinaPlay', 10 | repo: 'https://github.com/nepsyn/minaplay', 11 | license: 'AGPL-3.0', 12 | imports: [], 13 | providers: [PluginManagerCommand], 14 | }) 15 | export class PluginManagerPlugin {} 16 | -------------------------------------------------------------------------------- /packages/server/src/modules/plugin/builtin/user-manager/user-manager.plugin.ts: -------------------------------------------------------------------------------- 1 | import { MinaPlayPlugin } from '../../plugin.decorator.js'; 2 | import { MINAPLAY_VERSION } from '../../../../constants.js'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { User } from '../../../user/user.entity.js'; 5 | import { Permission } from '../../../authorization/permission.entity.js'; 6 | import { DefaultRootUserService } from './default-root-user.service.js'; 7 | import { UserManagerCommand } from './user-manager.command.js'; 8 | 9 | @MinaPlayPlugin({ 10 | id: 'user-manager', 11 | version: MINAPLAY_VERSION, 12 | description: 'Manage users in MinaPlay', 13 | author: 'MinaPlay', 14 | repo: 'https://github.com/nepsyn/minaplay', 15 | license: 'AGPL-3.0', 16 | imports: [TypeOrmModule.forFeature([User, Permission])], 17 | providers: [DefaultRootUserService, UserManagerCommand], 18 | }) 19 | export default class UserManagerPlugin {} 20 | -------------------------------------------------------------------------------- /packages/server/src/modules/plugin/constants.ts: -------------------------------------------------------------------------------- 1 | export const MINAPLAY_PLUGIN_METADATA = 'MINAPLAY_PLUGIN'; 2 | export const MINAPLAY_PLUGIN_ID_TOKEN = 'MINAPLAY_PLUGIN_ID_TOKEN'; 3 | export const PLUGIN_SERVICE_TOKEN = 'PLUGIN_SERVICE_TOKEN'; 4 | export const PLUGIN_SOURCE_PARSER_TOKEN = 'PLUGIN_SOURCE_PARSER_TOKEN'; 5 | 6 | export const MINAPLAY_LISTENER_METADATA = 'MINAPLAY_LISTENER_METADATA'; 7 | 8 | export const MINAPLAY_COMMAND_ARG_METADATA = 'MINAPLAY_COMMAND_ARG'; 9 | export const MINAPLAY_COMMAND_METADATA = 'MINAPLAY_COMMAND'; 10 | 11 | export const MESSAGE_TOKEN = 'MINAPLAY:MESSAGE'; 12 | export const LOCALE_TOKEN = 'MINAPLAY:LOCALE'; 13 | 14 | export const COMMAND_OPTIONS_TOKEN = 'MINAPLAY:COMMAND:OPTIONS'; 15 | export const COMMAND_ARGUMENTS_TOKEN = 'MINAPLAY:COMMAND:ARGUMENTS'; 16 | -------------------------------------------------------------------------------- /packages/server/src/modules/plugin/import-map-hooks.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { fileURLToPath, pathToFileURL } from 'node:url'; 3 | 4 | export async function resolve(specifier: string, context: object, nextResolve: Function) { 5 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 6 | const base = pathToFileURL(path.join(__dirname, '../../..')); 7 | if (specifier === '@minaplay/server') { 8 | return nextResolve(path.join(base.href, 'dist/index.js')); 9 | } 10 | return nextResolve(specifier.replace('@minaplay/server', base.href), context); 11 | } 12 | -------------------------------------------------------------------------------- /packages/server/src/modules/plugin/plugin-ref.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { type PluginService } from './plugin.service.js'; 3 | import { MINAPLAY_PLUGIN_ID_TOKEN, PLUGIN_SERVICE_TOKEN } from './constants.js'; 4 | 5 | @Injectable() 6 | export class PluginRef { 7 | constructor( 8 | @Inject(PLUGIN_SERVICE_TOKEN) private pluginService: PluginService, 9 | @Inject(MINAPLAY_PLUGIN_ID_TOKEN) private id: string, 10 | ) { 11 | if (!this.control) { 12 | throw new Error(`Cannot access PluginRef outside a plugin context`); 13 | } 14 | } 15 | 16 | private get control() { 17 | return this.pluginService.getControlById(this.id); 18 | } 19 | 20 | get isEnabled() { 21 | return this.control.enabled; 22 | } 23 | 24 | get metadata() { 25 | return this.control.metadata; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/server/src/modules/plugin/plugin.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { PluginService } from './plugin.service.js'; 3 | import { AuthorizationModule } from '../authorization/authorization.module.js'; 4 | import { UserModule } from '../user/user.module.js'; 5 | import { PluginController } from './plugin.controller.js'; 6 | import { PluginGateway } from './plugin.gateway.js'; 7 | import { PLUGIN_SERVICE_TOKEN } from './constants.js'; 8 | import { FileModule } from '../file/file.module.js'; 9 | 10 | @Global() 11 | @Module({ 12 | imports: [AuthorizationModule, UserModule, FileModule], 13 | controllers: [PluginController], 14 | providers: [ 15 | PluginService, 16 | PluginGateway, 17 | { 18 | provide: PLUGIN_SERVICE_TOKEN, 19 | useExisting: PluginService, 20 | }, 21 | ], 22 | exports: [PluginService, PLUGIN_SERVICE_TOKEN], 23 | }) 24 | export class PluginModule {} 25 | -------------------------------------------------------------------------------- /packages/server/src/modules/subscribe/download/adapters/downloader-adapters.ts: -------------------------------------------------------------------------------- 1 | import { WebtorrentAdapter } from './webtorrent.adapter.js'; 2 | import { DownloaderAdapter } from '../downloader-adapter.interface.js'; 3 | import { Type } from '@nestjs/common'; 4 | import { Aria2Adapter } from './aria2.adapter.js'; 5 | 6 | export const DOWNLOADER_ADAPTERS: Record> = { 7 | webtorrent: WebtorrentAdapter, 8 | aria2: Aria2Adapter, 9 | }; 10 | -------------------------------------------------------------------------------- /packages/server/src/modules/subscribe/download/download-item-state.interface.ts: -------------------------------------------------------------------------------- 1 | export interface DownloadItemState { 2 | totalLength: number; 3 | completedLength: number; 4 | downloadSpeed: number; 5 | progress: number; 6 | } 7 | -------------------------------------------------------------------------------- /packages/server/src/modules/subscribe/download/download-item.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNumber, IsOptional, IsString, MaxLength } from 'class-validator'; 3 | 4 | export class DownloadItemDto { 5 | @ApiProperty({ 6 | description: '下载链接', 7 | }) 8 | @IsString() 9 | url: string; 10 | 11 | @ApiProperty({ 12 | description: '下载项目标题', 13 | }) 14 | @IsOptional() 15 | @IsString() 16 | @MaxLength(255) 17 | name?: string; 18 | 19 | @ApiProperty({ 20 | description: '订阅源ID', 21 | required: false, 22 | }) 23 | @IsOptional() 24 | @IsNumber() 25 | sourceId?: number; 26 | } 27 | -------------------------------------------------------------------------------- /packages/server/src/modules/subscribe/download/download-task.interface.ts: -------------------------------------------------------------------------------- 1 | import { TypedEventEmitter } from '../../../utils/typed-event-emitter.js'; 2 | import { File } from '../../file/file.entity.js'; 3 | 4 | export type DownloadTaskEventMap = { 5 | done: (files: File[]) => any; 6 | failed: (error: Error | string) => any; 7 | remove: () => any; 8 | pause: () => any; 9 | start: () => any; 10 | }; 11 | 12 | export interface DownloadTask extends TypedEventEmitter { 13 | id: string; 14 | controller: T; 15 | pause: () => Promise; 16 | unpause: () => Promise; 17 | remove: () => Promise; 18 | } 19 | -------------------------------------------------------------------------------- /packages/server/src/modules/subscribe/download/downloader-adapter.interface.ts: -------------------------------------------------------------------------------- 1 | import { type DownloadService } from './download.service.js'; 2 | import { DownloadTask } from './download-task.interface.js'; 3 | import { DownloadItemState } from './download-item-state.interface.js'; 4 | 5 | export interface DownloaderAdapter { 6 | service: DownloadService; 7 | 8 | initialize?(): Promise; 9 | 10 | createTask(id: string, url: string, dir: string, trackers: string[]): Promise; 11 | 12 | getState(task: DownloadTask): Promise; 13 | } 14 | -------------------------------------------------------------------------------- /packages/server/src/modules/subscribe/parse-log/parse-log-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { ParseLog } from './parse-log.entity.js'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsDateString, IsEnum, IsOptional } from 'class-validator'; 4 | import { StatusEnum } from '../../../enums/status.enum.js'; 5 | import { ApiQueryDto } from '../../../common/api.query.dto.js'; 6 | 7 | export class ParseLogQueryDto extends ApiQueryDto { 8 | @ApiProperty({ 9 | description: '状态', 10 | required: false, 11 | enum: StatusEnum, 12 | }) 13 | @IsOptional() 14 | @IsEnum(StatusEnum) 15 | status?: StatusEnum; 16 | 17 | @ApiProperty({ 18 | description: '开始时间', 19 | required: false, 20 | }) 21 | @IsOptional() 22 | @IsDateString() 23 | start?: string; 24 | 25 | @ApiProperty({ 26 | description: '结束时间', 27 | required: false, 28 | }) 29 | @IsOptional() 30 | @IsDateString() 31 | end?: string; 32 | } 33 | -------------------------------------------------------------------------------- /packages/server/src/modules/subscribe/parse-log/parse-log.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, Relation } from 'typeorm'; 2 | import { Source } from '../source/source.entity.js'; 3 | import { Exclude } from 'class-transformer'; 4 | import { StatusEnum } from '../../../enums/status.enum.js'; 5 | import { DownloadItem } from '../download/download-item.entity.js'; 6 | 7 | /** 订阅解析日志 */ 8 | @Entity() 9 | export class ParseLog { 10 | /** id */ 11 | @PrimaryGeneratedColumn('uuid') 12 | id: string; 13 | 14 | /** 订阅源 */ 15 | @ManyToOne(() => Source, (source) => source.logs, { 16 | onDelete: 'CASCADE', 17 | }) 18 | source: Relation; 19 | 20 | /** 下载项目 */ 21 | @Exclude() 22 | @OneToMany(() => DownloadItem, (download) => download.log) 23 | downloads: Relation; 24 | 25 | /** 状态 */ 26 | @Column() 27 | status: StatusEnum; 28 | 29 | /** 错误内容 */ 30 | @Column({ 31 | nullable: true, 32 | type: 'text', 33 | }) 34 | error?: string; 35 | 36 | /** 创建日期 */ 37 | @CreateDateColumn() 38 | createAt: Date; 39 | } 40 | -------------------------------------------------------------------------------- /packages/server/src/modules/subscribe/parse-log/parse-log.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { ParseLog } from './parse-log.entity.js'; 4 | import { DeepPartial, FindManyOptions, FindOptionsWhere, Repository } from 'typeorm'; 5 | 6 | @Injectable() 7 | export class ParseLogService { 8 | constructor(@InjectRepository(ParseLog) private parseLogRepository: Repository) {} 9 | 10 | async save(log: DeepPartial) { 11 | return await this.parseLogRepository.save(log); 12 | } 13 | 14 | async findOneBy(where: FindOptionsWhere) { 15 | return await this.parseLogRepository.findOneBy(where); 16 | } 17 | 18 | async findAndCount(options?: FindManyOptions) { 19 | return await this.parseLogRepository.findAndCount(options); 20 | } 21 | 22 | async delete(where: FindOptionsWhere) { 23 | const result = await this.parseLogRepository.delete(where); 24 | return result.affected > 0; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/server/src/modules/subscribe/rule/rule-error-log.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, Relation } from 'typeorm'; 2 | import { Rule } from './rule.entity.js'; 3 | 4 | @Entity() 5 | export class RuleErrorLog { 6 | /** id */ 7 | @PrimaryGeneratedColumn('increment') 8 | id: number; 9 | 10 | /** 原始Entry */ 11 | @Column({ 12 | type: 'text', 13 | nullable: true, 14 | }) 15 | entry?: string; 16 | 17 | /** 错误内容 */ 18 | @Column({ 19 | type: 'text', 20 | }) 21 | error: string; 22 | 23 | @ManyToOne(() => Rule, { 24 | onDelete: 'CASCADE', 25 | }) 26 | rule: Relation; 27 | 28 | /** 创建时间 */ 29 | @CreateDateColumn() 30 | createAt: Date; 31 | } 32 | -------------------------------------------------------------------------------- /packages/server/src/modules/subscribe/rule/rule-error-log.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { DeepPartial, FindManyOptions, FindOptionsWhere, Repository } from 'typeorm'; 4 | import { RuleErrorLog } from './rule-error-log.entity.js'; 5 | 6 | @Injectable() 7 | export class RuleErrorLogService { 8 | constructor(@InjectRepository(RuleErrorLog) private ruleErrorLogRepository: Repository) {} 9 | 10 | async save(log: DeepPartial) { 11 | return await this.ruleErrorLogRepository.save(log); 12 | } 13 | 14 | async findOneBy(where: FindOptionsWhere) { 15 | return await this.ruleErrorLogRepository.findOneBy(where); 16 | } 17 | 18 | async findAndCount(options?: FindManyOptions) { 19 | return await this.ruleErrorLogRepository.findAndCount(options); 20 | } 21 | 22 | async delete(where: FindOptionsWhere) { 23 | const result = await this.ruleErrorLogRepository.delete(where); 24 | return result.affected > 0; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/server/src/modules/subscribe/rule/rule-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from './rule.entity.js'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsInt, IsOptional, IsString } from 'class-validator'; 4 | import { Transform } from 'class-transformer'; 5 | import { ApiQueryDto } from '../../../common/api.query.dto.js'; 6 | 7 | export class RuleQueryDto extends ApiQueryDto { 8 | @ApiProperty({ 9 | description: '查询关键字', 10 | required: false, 11 | }) 12 | @IsOptional() 13 | @IsString() 14 | keyword?: string; 15 | 16 | @ApiProperty({ 17 | description: '订阅源id', 18 | required: false, 19 | }) 20 | @Transform(({ value }) => Number(value)) 21 | @IsOptional() 22 | @IsInt() 23 | sourceId?: number; 24 | } 25 | -------------------------------------------------------------------------------- /packages/server/src/modules/subscribe/rule/rule.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsArray, IsInt, IsOptional, IsString, MaxLength } from 'class-validator'; 3 | 4 | export class RuleDto { 5 | @ApiProperty({ 6 | description: '代码规则', 7 | required: false, 8 | }) 9 | @IsOptional() 10 | @IsString() 11 | @MaxLength(20480) 12 | code: string; 13 | 14 | @ApiProperty({ 15 | description: '订阅规则备注', 16 | required: false, 17 | }) 18 | @IsString() 19 | @IsOptional() 20 | @MaxLength(60) 21 | remark?: string; 22 | 23 | @ApiProperty({ 24 | description: '订阅源id', 25 | required: false, 26 | type: [Number], 27 | }) 28 | @IsOptional() 29 | @IsArray() 30 | @IsInt({ 31 | each: true, 32 | }) 33 | sourceIds?: number[]; 34 | } 35 | -------------------------------------------------------------------------------- /packages/server/src/modules/subscribe/rule/rule.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | JoinTable, 6 | ManyToMany, 7 | ManyToOne, 8 | OneToMany, 9 | PrimaryGeneratedColumn, 10 | Relation, 11 | UpdateDateColumn, 12 | } from 'typeorm'; 13 | import { Exclude, Expose } from 'class-transformer'; 14 | import { File } from '../../file/file.entity.js'; 15 | import fs from 'fs-extra'; 16 | import { Source } from '../source/source.entity.js'; 17 | import { DownloadItem } from '../download/download-item.entity.js'; 18 | 19 | /** 订阅规则 */ 20 | @Entity() 21 | export class Rule { 22 | /** id */ 23 | @PrimaryGeneratedColumn('increment') 24 | id: number; 25 | 26 | /** Parser Meta */ 27 | @Exclude() 28 | @Column({ 29 | nullable: true, 30 | }) 31 | parserMeta?: string; 32 | 33 | /** 备注 */ 34 | @Column({ 35 | nullable: true, 36 | }) 37 | remark?: string; 38 | 39 | /** 规则代码文件 */ 40 | @Exclude() 41 | @ManyToOne(() => File, { 42 | onDelete: 'SET NULL', 43 | nullable: true, 44 | eager: true, 45 | }) 46 | file?: Relation; 47 | 48 | /** 下载内容 */ 49 | @Exclude() 50 | @OneToMany(() => DownloadItem, (download) => download.rule) 51 | downloads: Relation>; 52 | 53 | /** 订阅源 */ 54 | @ManyToMany(() => Source, (source) => source.rules, { 55 | onDelete: 'CASCADE', 56 | eager: true, 57 | }) 58 | @JoinTable() 59 | sources: Relation; 60 | 61 | @Expose() 62 | get code() { 63 | return this.file?.isExist ? fs.readFileSync(this.file.path).toString() : undefined; 64 | } 65 | 66 | /** 创建时间 */ 67 | @CreateDateColumn() 68 | createAt: Date; 69 | 70 | /** 更新时间 */ 71 | @UpdateDateColumn() 72 | updateAt: Date; 73 | } 74 | -------------------------------------------------------------------------------- /packages/server/src/modules/subscribe/source/source-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator'; 3 | import { Source } from './source.entity.js'; 4 | import { Transform } from 'class-transformer'; 5 | import { ApiQueryDto } from '../../../common/api.query.dto.js'; 6 | 7 | export class SourceQueryDto extends ApiQueryDto { 8 | @ApiProperty({ 9 | description: '查询关键字', 10 | required: false, 11 | }) 12 | @IsOptional() 13 | @IsString() 14 | keyword?: string; 15 | 16 | @ApiProperty({ 17 | description: '是否启用', 18 | required: false, 19 | enum: [0, 1], 20 | }) 21 | @Transform(({ value }) => Boolean(Number(value))) 22 | @IsOptional() 23 | @IsBoolean() 24 | enabled?: boolean; 25 | 26 | @ApiProperty({ 27 | description: '创建用户id', 28 | required: false, 29 | }) 30 | @Transform(({ value }) => Number(value)) 31 | @IsOptional() 32 | @IsInt() 33 | userId?: number; 34 | } 35 | -------------------------------------------------------------------------------- /packages/server/src/modules/subscribe/source/source.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator'; 3 | 4 | export class SourceDto { 5 | @ApiProperty({ 6 | description: '订阅源 url', 7 | required: false, 8 | }) 9 | @IsString() 10 | @IsOptional() 11 | @MaxLength(200) 12 | url?: string; 13 | 14 | @ApiProperty({ 15 | description: '订阅源标题', 16 | required: false, 17 | }) 18 | @IsString() 19 | @IsOptional() 20 | @MaxLength(40) 21 | title?: string; 22 | 23 | @ApiProperty({ 24 | description: '订阅源备注', 25 | required: false, 26 | }) 27 | @IsString() 28 | @IsOptional() 29 | @MaxLength(40) 30 | remark?: string; 31 | 32 | @ApiProperty({ 33 | description: '更新周期 cron 表达式', 34 | required: false, 35 | default: '0 */30 * * * *', 36 | }) 37 | @IsString() 38 | @IsOptional() 39 | cron?: string; 40 | 41 | @ApiProperty({ 42 | description: '是否启用', 43 | required: false, 44 | }) 45 | @IsBoolean() 46 | @IsOptional() 47 | enabled?: boolean; 48 | } 49 | -------------------------------------------------------------------------------- /packages/server/src/modules/subscribe/subscribe.module-definition.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurableModuleBuilder } from '@nestjs/common'; 2 | import { SubscribeModuleOptions } from './subscribe.module.interface.js'; 3 | 4 | export const { 5 | ConfigurableModuleClass: SubscribeConfigurableModule, 6 | MODULE_OPTIONS_TOKEN: SUBSCRIBE_MODULE_OPTIONS_TOKEN, 7 | } = new ConfigurableModuleBuilder({ moduleName: 'Subscribe' }) 8 | .setExtras({ isGlobal: true }, (definition, extras) => ({ 9 | ...definition, 10 | global: extras.isGlobal, 11 | })) 12 | .build(); 13 | -------------------------------------------------------------------------------- /packages/server/src/modules/subscribe/subscribe.module.interface.ts: -------------------------------------------------------------------------------- 1 | export interface SubscribeModuleOptions { 2 | downloader?: 'aria2' | 'webtorrent'; 3 | trackerAutoUpdate?: boolean; 4 | trackerUpdateUrl?: string; 5 | httpProxy?: string; 6 | 7 | aria2RpcHost?: string; 8 | aria2RpcPort?: number; 9 | aria2RpcPath?: string; 10 | aria2RpcSecret?: string; 11 | } 12 | -------------------------------------------------------------------------------- /packages/server/src/modules/system/system.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { FileModule } from '../file/file.module.js'; 3 | import { AuthorizationModule } from '../authorization/authorization.module.js'; 4 | import { SystemService } from './system.service.js'; 5 | import { SystemController } from './system.controller.js'; 6 | 7 | @Module({ 8 | imports: [FileModule, AuthorizationModule], 9 | providers: [SystemService], 10 | controllers: [SystemController], 11 | exports: [SystemService], 12 | }) 13 | export class SystemModule {} 14 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/user-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { User } from './user.entity.js'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsInt, IsOptional, IsString } from 'class-validator'; 4 | import { Transform } from 'class-transformer'; 5 | import { ApiQueryDto } from '../../common/api.query.dto.js'; 6 | 7 | export class UserQueryDto extends ApiQueryDto { 8 | @ApiProperty({ 9 | description: '查询关键字', 10 | required: false, 11 | }) 12 | @IsOptional() 13 | @IsString() 14 | keyword?: string; 15 | 16 | @ApiProperty({ 17 | description: '用户id', 18 | required: false, 19 | }) 20 | @Transform(({ value }) => Number(value)) 21 | @IsOptional() 22 | @IsInt() 23 | id?: number; 24 | 25 | @ApiProperty({ 26 | description: '用户名', 27 | required: false, 28 | }) 29 | @IsOptional() 30 | @IsString() 31 | username?: string; 32 | } 33 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsBoolean, IsOptional, IsUUID } from 'class-validator'; 3 | 4 | export class UserDto { 5 | @ApiProperty({ 6 | description: '用户头像文件id', 7 | required: false, 8 | }) 9 | @IsOptional() 10 | @IsUUID() 11 | avatarFileId?: string; 12 | 13 | @ApiProperty({ 14 | description: '是否允许通知', 15 | required: false, 16 | }) 17 | @IsOptional() 18 | @IsBoolean() 19 | notify?: boolean; 20 | } 21 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/user.entity.subscriber.ts: -------------------------------------------------------------------------------- 1 | import { DataSource, EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm'; 2 | import { User } from './user.entity.js'; 3 | import { FileService } from '../file/file.service.js'; 4 | import { createIdenticon } from '../../utils/create-identicon.util.js'; 5 | import { generateMD5 } from '../../utils/generate-md5.util.js'; 6 | import { USER_UPLOAD_IMAGE_DIR } from '../../constants.js'; 7 | import { randomUUID } from 'node:crypto'; 8 | import path from 'node:path'; 9 | import { ApplicationLogger } from '../../common/application.logger.service.js'; 10 | import { FileSourceEnum } from '../../enums/index.js'; 11 | 12 | @EventSubscriber() 13 | export class UserEntitySubscriber implements EntitySubscriberInterface { 14 | private logger = new ApplicationLogger(UserEntitySubscriber.name); 15 | 16 | constructor(dataSource: DataSource, private fileService: FileService) { 17 | dataSource.subscribers.push(this); 18 | } 19 | 20 | listenTo() { 21 | return User; 22 | } 23 | 24 | async beforeInsert(event: InsertEvent) { 25 | if (!event.entity.avatar) { 26 | try { 27 | const filename = randomUUID().replace(/-/g, '') + '.png'; 28 | const filepath = path.join(USER_UPLOAD_IMAGE_DIR, filename); 29 | await createIdenticon(await generateMD5(event.entity.username), filepath); 30 | event.entity.avatar = await this.fileService.saveLocalFile(filepath, FileSourceEnum.AUTO_GENERATED); 31 | } catch (error) { 32 | this.logger.error( 33 | `Create identicon for user '${event.entity.username}' failed`, 34 | error.stack, 35 | UserEntitySubscriber.name, 36 | ); 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserService } from './user.service.js'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { User } from './user.entity.js'; 5 | import { UserController } from './user.controller.js'; 6 | import { FileModule } from '../file/file.module.js'; 7 | import { UserEntitySubscriber } from './user.entity.subscriber.js'; 8 | 9 | @Module({ 10 | imports: [FileModule, TypeOrmModule.forFeature([User])], 11 | providers: [UserService, UserEntitySubscriber], 12 | controllers: [UserController], 13 | exports: [UserService], 14 | }) 15 | export class UserModule {} 16 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { User } from './user.entity.js'; 4 | import { DeepPartial, FindManyOptions, FindOptionsWhere, Repository } from 'typeorm'; 5 | 6 | @Injectable() 7 | export class UserService { 8 | constructor(@InjectRepository(User) private userRepository: Repository) {} 9 | 10 | async save(user: DeepPartial) { 11 | return await this.userRepository.save(user); 12 | } 13 | 14 | async findOneBy(where: FindOptionsWhere) { 15 | return await this.userRepository.findOneBy(where); 16 | } 17 | 18 | async findAndCount(options?: FindManyOptions) { 19 | return await this.userRepository.findAndCount(options); 20 | } 21 | 22 | async delete(where: FindOptionsWhere) { 23 | const result = await this.userRepository.delete(where); 24 | return result.affected > 0; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/server/src/utils/build-exception.util.ts: -------------------------------------------------------------------------------- 1 | import { HttpException } from '@nestjs/common'; 2 | import { WsException } from '@nestjs/websockets'; 3 | import { ErrorCodeEnum } from '../enums/error-code.enum.js'; 4 | 5 | type NestJsExceptionConstructor = { 6 | new (errorObject: object): HttpException | WsException; 7 | }; 8 | 9 | export function buildException(ctor: NestJsExceptionConstructor, code: ErrorCodeEnum, message?: string) { 10 | return new ctor({ 11 | code, 12 | message: message || ErrorCodeEnum[code].replace(/_/g, ' ').toUpperCase(), 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /packages/server/src/utils/build-plugin-command.ts: -------------------------------------------------------------------------------- 1 | import { MinaPlayCommandMetadata } from '../modules/index.js'; 2 | import { Command } from 'commander'; 3 | 4 | export function buildPluginCommand(metadata: MinaPlayCommandMetadata): Command { 5 | const command = metadata.commandFactory(); 6 | for (const subcommandMetadata of metadata.subcommands) { 7 | command.addCommand(buildPluginCommand(subcommandMetadata)); 8 | } 9 | return command; 10 | } 11 | -------------------------------------------------------------------------------- /packages/server/src/utils/compile-template.util.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import fs from 'fs-extra'; 3 | import Handlebars from 'handlebars'; 4 | 5 | export async function compileTemplate(file: string, searchDirs: string[]) { 6 | const candidates = searchDirs.map((dir) => path.join(dir, file)); 7 | 8 | for (const candidate of candidates) { 9 | if (await fs.pathExists(candidate)) { 10 | const code = await fs.readFile(candidate); 11 | return Handlebars.compile(code.toString()); 12 | } 13 | } 14 | 15 | return null; 16 | } 17 | -------------------------------------------------------------------------------- /packages/server/src/utils/encrypt-password.util.ts: -------------------------------------------------------------------------------- 1 | import { hash } from 'bcrypt'; 2 | 3 | export async function encryptPassword(password: string) { 4 | return await hash(password, 10); 5 | } 6 | -------------------------------------------------------------------------------- /packages/server/src/utils/generate-md5.util.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'node:stream'; 2 | import { createHash } from 'node:crypto'; 3 | 4 | export function generateMD5(chunk: Readable | string): Promise { 5 | return new Promise((resolve) => { 6 | const hash = createHash('md5'); 7 | if (typeof chunk === 'string') { 8 | resolve(hash.update(chunk).digest('hex')); 9 | } else { 10 | chunk.on('data', (chunk) => { 11 | hash.update(chunk); 12 | }); 13 | chunk.on('end', () => { 14 | resolve(hash.digest('hex')); 15 | }); 16 | } 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /packages/server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "test", 6 | "dist", 7 | "public", 8 | "data", 9 | "resources", 10 | "**/*spec.ts" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "NodeNext", 4 | "moduleResolution": "NodeNext", 5 | "declaration": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "ES2017", 9 | "outDir": "./dist", 10 | "esModuleInterop": true, 11 | "removeComments": true, 12 | "allowSyntheticDefaultImports": true, 13 | "sourceMap": true, 14 | "baseUrl": ".", 15 | "skipLibCheck": true, 16 | "strictNullChecks": false, 17 | "noImplicitAny": false, 18 | "strictBindCallApply": false, 19 | "forceConsistentCasingInFileNames": false, 20 | "noFallthroughCasesInSwitch": false 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/web/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | not ie 11 5 | -------------------------------------------------------------------------------- /packages/web/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /packages/web/.env.template: -------------------------------------------------------------------------------- 1 | VITE_API_HOST="" 2 | -------------------------------------------------------------------------------- /packages/web/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/eslint-config-typescript', 10 | ], 11 | rules: { 12 | 'vue/multi-word-component-names': 'off', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /packages/web/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /packages/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | MinaPlay 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /packages/web/public/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/public/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /packages/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/public/favicon.ico -------------------------------------------------------------------------------- /packages/web/public/maskable-icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/public/maskable-icon-512x512.png -------------------------------------------------------------------------------- /packages/web/public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/public/pwa-192x192.png -------------------------------------------------------------------------------- /packages/web/public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/public/pwa-512x512.png -------------------------------------------------------------------------------- /packages/web/public/pwa-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/public/pwa-64x64.png -------------------------------------------------------------------------------- /packages/web/src/api/enums/auth-action.enum.ts: -------------------------------------------------------------------------------- 1 | export enum AuthActionEnum { 2 | LOGIN = 'LOGIN', 3 | LOGOUT = 'LOGOUT', 4 | REFRESH = 'REFRESH', 5 | GRANT = 'GRANT', 6 | CHANGE_PASSWORD = 'CHANGE_PASSWORD', 7 | } 8 | -------------------------------------------------------------------------------- /packages/web/src/api/enums/error-code.enum.ts: -------------------------------------------------------------------------------- 1 | /** 应用程序错误代码 */ 2 | export enum ErrorCodeEnum { 3 | /** 错误的请求 */ 4 | BAD_REQUEST = 0x0001, 5 | /** 服务器内部错误 */ 6 | INTERNAL_SERVER_ERROR = 0x0002, 7 | /** 错误的查询 */ 8 | QUERY_FAILED = 0x0003, 9 | /** 系统未记录错误 */ 10 | UNKNOWN_ERROR = 0x0004, 11 | /** 缺少同步字段 */ 12 | NO_SYNC_FIELD = 0x0005, 13 | /** 请求资源不存在 */ 14 | NOT_FOUND = 0x0006, 15 | /** 处理超时 */ 16 | TIMEOUT = 0x0007, 17 | /** 未实现 */ 18 | NOT_IMPLEMENTED = 0x0008, 19 | 20 | /** 用户名或密码错误 */ 21 | WRONG_USERNAME_OR_PASSWORD = 0x0101, 22 | /** 用户未登录 */ 23 | USER_NOT_LOGGED_IN = 0x0102, 24 | /** 用户缺少权限 */ 25 | NO_PERMISSION = 0x0103, 26 | /** 未经授权的 Token */ 27 | INVALID_TOKEN = 0x0104, 28 | /** 用户名已被使用 */ 29 | USERNAME_ALREADY_OCCUPIED = 0x0105, 30 | 31 | /** 错误的文件内容 */ 32 | INVALID_FILE = 0x0301, 33 | /** 错误的图片文件类型 */ 34 | INVALID_IMAGE_FILE_TYPE = 0x0302, 35 | /** 错误的视频文件类型 */ 36 | INVALID_VIDEO_FILE_TYPE = 0x0303, 37 | 38 | /** 剧集已存在 */ 39 | DUPLICATE_SERIES = 0x0401, 40 | 41 | /** 订阅源格式错误 */ 42 | INVALID_SUBSCRIBE_SOURCE_FORMAT = 0x0501, 43 | /** 订阅规则代码错误 */ 44 | INVALID_SUBSCRIBE_RULE_CODE = 0x0502, 45 | /** 重复下载 */ 46 | DUPLICATED_DOWNLOAD_ITEM = 0x0503, 47 | 48 | /** 用户被禁止发言 */ 49 | USER_CHAT_MUTED = 0x0601, 50 | /** 用户被禁止语音 */ 51 | USER_VOICE_MUTED = 0x0602, 52 | /** 直播语音连接建立失败 */ 53 | VOICE_SERVICE_ESTABLISH_FAILED = 0x0603, 54 | /** 直播房间密码错误 */ 55 | WRONG_LIVE_PASSWORD = 0x0604, 56 | /** 存在多个连接 */ 57 | DUPLICATED_CONNECTION = 0x0605, 58 | 59 | /** 重复的通知服务 */ 60 | DUPLICATED_NOTIFICATION_SERVICE = 0x0701, 61 | /** 邮箱验证码错误 */ 62 | WRONG_EMAIL_VERIFY_CODE = 0x0702, 63 | 64 | /** 内置插件不可卸载 */ 65 | BUILTIN_PLUGIN_NOT_UNINSTALLABLE = 0x0801, 66 | } 67 | -------------------------------------------------------------------------------- /packages/web/src/api/enums/file-source.enum.ts: -------------------------------------------------------------------------------- 1 | export enum FileSourceEnum { 2 | USER_UPLOAD = 'USER_UPLOAD', 3 | DOWNLOAD = 'DOWNLOAD', 4 | AUTO_GENERATED = 'AUTO_GENERATED', 5 | LOCAL = 'LOCAL', 6 | NETWORK = 'NETWORK', 7 | } 8 | -------------------------------------------------------------------------------- /packages/web/src/api/enums/notification-event.enum.ts: -------------------------------------------------------------------------------- 1 | export enum NotificationEventEnum { 2 | NEW_EPISODE = 'new-episode', 3 | NEW_MEDIA = 'new-media', 4 | } 5 | -------------------------------------------------------------------------------- /packages/web/src/api/enums/notification-service.enum.ts: -------------------------------------------------------------------------------- 1 | export enum NotificationServiceEnum { 2 | EMAIL = 'EMAIL', 3 | WS = 'WS', 4 | SERVER_CHAN = 'SERVER_CHAN', 5 | TELEGRAM = 'TELEGRAM', 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/src/api/enums/permission.enum.ts: -------------------------------------------------------------------------------- 1 | export enum PermissionEnum { 2 | /** 最高权限 */ 3 | ROOT_OP = '*:*', 4 | 5 | /** 文件管理 */ 6 | FILE_OP = 'FILE:*', 7 | /** 上传图片 */ 8 | FILE_UPLOAD_IMAGE = 'FILE:UPLOAD:IMAGE', 9 | /** 上传视频 */ 10 | FILE_UPLOAD_VIDEO = 'FILE:UPLOAD:VIDEO', 11 | 12 | /** 媒体管理 */ 13 | MEDIA_OP = 'MEDIA:*', 14 | /** 媒体查看 */ 15 | MEDIA_VIEW = 'MEDIA:VIEW', 16 | 17 | /** 订阅管理 */ 18 | SUBSCRIBE_OP = 'SUBSCRIBE:*', 19 | /** 订阅查看 */ 20 | SUBSCRIBE_VIEW = 'SUBSCRIBE:VIEW', 21 | 22 | /** 直播管理 */ 23 | LIVE_OP = 'LIVE:*', 24 | /** 直播查看 */ 25 | LIVE_VIEW = 'LIVE:VIEW', 26 | } 27 | -------------------------------------------------------------------------------- /packages/web/src/api/enums/status.enum.ts: -------------------------------------------------------------------------------- 1 | export enum StatusEnum { 2 | PENDING = 'PENDING', 3 | PAUSED = 'PAUSED', 4 | SUCCESS = 'SUCCESS', 5 | FAILED = 'FAILED', 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/src/api/interfaces/auth.interface.ts: -------------------------------------------------------------------------------- 1 | import { UserEntity } from './user.interface'; 2 | import { PermissionEnum } from '@/api/enums/permission.enum'; 3 | import { AuthActionEnum } from '@/api/enums/auth-action.enum'; 4 | import { ApiQueryDto } from '@/api/interfaces/common.interface'; 5 | 6 | export interface LoginDto { 7 | username: string; 8 | password: string; 9 | } 10 | 11 | export interface ChangePasswordDto { 12 | old?: string; 13 | current: string; 14 | } 15 | 16 | export type ChangePasswordData = ChangePasswordDto; 17 | 18 | export interface CreateUserDto { 19 | username: string; 20 | password: string; 21 | permissionNames: PermissionEnum[]; 22 | } 23 | 24 | export interface AuthData extends UserEntity { 25 | token: string; 26 | } 27 | 28 | export interface PermissionDto { 29 | permissionNames: PermissionEnum[]; 30 | } 31 | 32 | export interface ActionLogEntity { 33 | id: string; 34 | ip?: string; 35 | action: AuthActionEnum; 36 | operator: UserEntity; 37 | target: UserEntity; 38 | extra?: string; 39 | createAt: Date; 40 | } 41 | 42 | export interface ActionLogQueryDto extends ApiQueryDto { 43 | operatorId?: number; 44 | ip?: string; 45 | action?: AuthActionEnum; 46 | start?: Date; 47 | end?: Date; 48 | } 49 | -------------------------------------------------------------------------------- /packages/web/src/api/interfaces/common.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ApiError { 2 | code: number; 3 | message: string; 4 | } 5 | 6 | export interface ApiQueryDto { 7 | page?: number; 8 | size?: number; 9 | sort?: `${keyof T & string}:${'ASC' | 'DESC'}` | `${keyof T & string}:${'ASC' | 'DESC'}`[]; 10 | } 11 | 12 | export interface ApiQueryResult { 13 | items: T[]; 14 | page: number; 15 | size: number; 16 | total: number; 17 | } 18 | -------------------------------------------------------------------------------- /packages/web/src/api/interfaces/file.interface.ts: -------------------------------------------------------------------------------- 1 | import { ApiQueryDto } from '@/api/interfaces/common.interface'; 2 | import { FileSourceEnum } from '@/api/enums/file-source.enum'; 3 | 4 | export interface FileEntity { 5 | /** 媒体id */ 6 | id: string; 7 | /** 文件名 */ 8 | name: string; 9 | /** 文件大小(字节) */ 10 | size: number; 11 | /** 文件 md5 */ 12 | md5: string; 13 | /** 文件 url */ 14 | url?: string; 15 | /** 文件 mimetype */ 16 | mimetype?: string; 17 | /** 文件来源 */ 18 | source: FileSourceEnum; 19 | /** 创建时间 */ 20 | createAt: Date; 21 | /** 修改时间 */ 22 | updateAt: Date; 23 | } 24 | 25 | export interface FileQueryDto extends ApiQueryDto { 26 | keyword?: string; 27 | md5?: string; 28 | source?: FileSourceEnum; 29 | userId?: number; 30 | start?: Date; 31 | end?: Date; 32 | } 33 | -------------------------------------------------------------------------------- /packages/web/src/api/interfaces/media.interface.ts: -------------------------------------------------------------------------------- 1 | import { FileEntity } from '@/api/interfaces/file.interface'; 2 | import { ApiQueryDto } from '@/api/interfaces/common.interface'; 3 | import { UserEntity } from '@/api/interfaces/user.interface'; 4 | 5 | export interface MediaEntity { 6 | /** id */ 7 | id: string; 8 | /** 标题 */ 9 | name: string; 10 | /** 简介 */ 11 | description?: string; 12 | /** 是否公开 */ 13 | isPublic: boolean; 14 | /** 封面图片 */ 15 | poster?: FileEntity; 16 | /**对应文件 */ 17 | file?: FileEntity; 18 | /** duration */ 19 | duration?: number; 20 | /** 附件 */ 21 | attachments: FileEntity[]; 22 | /** 创建时间 */ 23 | createAt: Date; 24 | /** 更新时间 */ 25 | updateAt: Date; 26 | } 27 | 28 | export interface MediaDto { 29 | name?: string; 30 | description?: string; 31 | isPublic?: boolean; 32 | fileId?: string; 33 | posterFileId?: string; 34 | } 35 | 36 | export interface MediaQueryDto extends ApiQueryDto { 37 | keyword?: string; 38 | start?: string; 39 | end?: string; 40 | } 41 | 42 | export interface ViewHistoryEntity { 43 | id: string; 44 | media: MediaEntity; 45 | episodeId?: number; 46 | user: UserEntity; 47 | progress?: number; 48 | createAt: Date; 49 | updateAt: Date; 50 | } 51 | 52 | export interface ViewHistoryDto { 53 | progress?: number; 54 | episodeId?: number | null; 55 | } 56 | -------------------------------------------------------------------------------- /packages/web/src/api/interfaces/message.interface.ts: -------------------------------------------------------------------------------- 1 | import { SeriesEntity } from '@/api/interfaces/series.interface'; 2 | import { MediaEntity } from '@/api/interfaces/media.interface'; 3 | 4 | export interface MinaPlayText { 5 | type: 'Text'; 6 | color?: string; 7 | content: string; 8 | } 9 | 10 | export interface MinaPlayNetworkImage { 11 | type: 'NetworkImage'; 12 | url: string; 13 | } 14 | 15 | export interface MinaPlayBase64Image { 16 | type: 'Base64Image'; 17 | content: string; 18 | } 19 | 20 | export interface MinaPlayAction { 21 | type: 'Action'; 22 | value: string; 23 | text: MinaPlayText; 24 | } 25 | 26 | export interface MinaPlayConsumableGroup { 27 | type: 'ConsumableGroup'; 28 | id: string; 29 | items: MinaPlayMessage[]; 30 | } 31 | 32 | export interface MinaPlayConsumed { 33 | type: 'Consumed'; 34 | id: string; 35 | } 36 | 37 | export interface MinaPlayTimeout { 38 | type: 'Timeout'; 39 | ms: number; 40 | } 41 | 42 | export interface MinaPlayPending { 43 | type: 'Pending'; 44 | color?: string; 45 | } 46 | 47 | export interface MinaPlayMarkdownText { 48 | type: 'MarkdownText'; 49 | content: string; 50 | } 51 | 52 | export interface MinaPlayResourceSeries { 53 | type: 'ResourceSeries'; 54 | series: SeriesEntity; 55 | } 56 | 57 | export interface MinaPlayResourceMedia { 58 | type: 'ResourceMedia'; 59 | media: MediaEntity; 60 | } 61 | 62 | export type MinaPlayMessage = 63 | | MinaPlayText 64 | | MinaPlayNetworkImage 65 | | MinaPlayBase64Image 66 | | MinaPlayAction 67 | | MinaPlayConsumableGroup 68 | | MinaPlayConsumed 69 | | MinaPlayTimeout 70 | | MinaPlayPending 71 | | MinaPlayMarkdownText 72 | | MinaPlayResourceSeries 73 | | MinaPlayResourceMedia; 74 | -------------------------------------------------------------------------------- /packages/web/src/api/interfaces/notification.interface.ts: -------------------------------------------------------------------------------- 1 | import { NotificationServiceEnum } from '@/api/enums/notification-service.enum'; 2 | import { NotificationEventEnum } from '@/api/enums/notification-event.enum'; 3 | import { EpisodeEntity } from '@/api/interfaces/series.interface'; 4 | import { MediaEntity } from '@/api/interfaces/media.interface'; 5 | 6 | export interface NotificationMetaEntity { 7 | /** id */ 8 | id: number; 9 | /** 通知类型 */ 10 | service: NotificationServiceEnum; 11 | /** 是否启用 */ 12 | enabled: boolean; 13 | /** 通知内容 */ 14 | subscribes: NotificationEventEnum[]; 15 | /** 配置信息 */ 16 | config?: string; 17 | /** 创建时间 */ 18 | createAt: Date; 19 | /** 修改时间 */ 20 | updateAt: Date; 21 | } 22 | 23 | export interface NotificationMetaDto { 24 | enabled?: boolean; 25 | subscribes?: NotificationEventEnum[]; 26 | } 27 | 28 | export type NotificationTypeMap = { 29 | [NotificationServiceEnum.WS]: {}; 30 | [NotificationServiceEnum.EMAIL]: { 31 | email: string; 32 | }; 33 | [NotificationServiceEnum.SERVER_CHAN]: { 34 | token: string; 35 | }; 36 | [NotificationServiceEnum.TELEGRAM]: { 37 | token: string; 38 | chatId: string; 39 | }; 40 | }; 41 | 42 | export interface NotificationBindDto { 43 | service: T; 44 | config: NotificationTypeMap[T]; 45 | } 46 | 47 | export type NotificationEventMap = { 48 | [NotificationEventEnum.NEW_EPISODE]: { 49 | episode: EpisodeEntity; 50 | time: Date; 51 | }; 52 | [NotificationEventEnum.NEW_MEDIA]: { 53 | media: MediaEntity; 54 | time: Date; 55 | }; 56 | }; 57 | 58 | export type NotificationItem = { 59 | [K in keyof NotificationEventMap]: { 60 | event: K; 61 | data: NotificationEventMap[K]; 62 | read: boolean; 63 | }; 64 | }[keyof NotificationEventMap]; 65 | -------------------------------------------------------------------------------- /packages/web/src/api/interfaces/system.interface.ts: -------------------------------------------------------------------------------- 1 | export interface SystemStatus { 2 | startAt: Date; 3 | version: string; 4 | memory: { 5 | total: number; 6 | free: number; 7 | used: number; 8 | }; 9 | disk: { 10 | disk: string; 11 | total: number; 12 | free: number; 13 | used: number; 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /packages/web/src/api/interfaces/user.interface.ts: -------------------------------------------------------------------------------- 1 | import { PermissionEnum } from '@/api/enums/permission.enum'; 2 | import { FileEntity } from '@/api/interfaces/file.interface'; 3 | import { ApiQueryDto } from '@/api/interfaces/common.interface'; 4 | import { NotificationMetaEntity } from '@/api/interfaces/notification.interface'; 5 | 6 | export interface UserEntity { 7 | /** id */ 8 | id: number; 9 | /** 用户名 */ 10 | username: string; 11 | /** 创建时间 */ 12 | createAt: Date; 13 | /** 是否启用通知 */ 14 | notify?: boolean; 15 | /** 通知服务 */ 16 | metas: NotificationMetaEntity[]; 17 | /** 邮箱地址 */ 18 | email?: string; 19 | /** 修改时间 */ 20 | updateAt: Date; 21 | /** 头像文件 id */ 22 | avatar?: FileEntity; 23 | /** 权限列表 */ 24 | permissionNames: PermissionEnum[]; 25 | } 26 | 27 | export interface UserDto { 28 | avatarFileId?: string; 29 | notify?: boolean; 30 | } 31 | 32 | export interface UserQueryDto extends ApiQueryDto { 33 | keyword?: string; 34 | username?: string; 35 | } 36 | -------------------------------------------------------------------------------- /packages/web/src/api/templates/default.ts: -------------------------------------------------------------------------------- 1 | const hooks: RuleHooks = { 2 | validate: (entry, ctx) => { 3 | return false; 4 | }, 5 | describe: (entry, file, ctx) => { 6 | return {}; 7 | }, 8 | }; 9 | export default hooks; 10 | -------------------------------------------------------------------------------- /packages/web/src/api/templates/download-all.ts: -------------------------------------------------------------------------------- 1 | const hooks: RuleHooks = { 2 | validate: () => { 3 | return true; 4 | }, 5 | }; 6 | export default hooks; 7 | -------------------------------------------------------------------------------- /packages/web/src/api/templates/filter.ts: -------------------------------------------------------------------------------- 1 | // text includes in entry title 2 | const includes = ['1080P', 'HEVC']; 3 | // text excludes in entry title 4 | const excludes = ['Un-Sub']; 5 | const hooks: RuleHooks = { 6 | validate: (entry) => { 7 | return includes.every((text) => entry.title.includes(text)) && !excludes.some((text) => entry.title.includes(text)); 8 | }, 9 | }; 10 | export default hooks; 11 | -------------------------------------------------------------------------------- /packages/web/src/api/templates/regexp.ts: -------------------------------------------------------------------------------- 1 | // replace regexp expression to your own subscription 2 | const regexp = /NO GAME NO LIVE ([\d.]+)([vV]\d+)?/; 3 | const hooks: RuleHooks = { 4 | validate(entry) { 5 | return regexp.test(entry.title); 6 | }, 7 | describe(entry) { 8 | const groups = entry.title.match(regexp); 9 | return { 10 | series: { 11 | name: 'NO GAME NO LIVE', // name of this series 12 | season: '01', // season of this series 13 | }, 14 | episode: { 15 | title: entry.title, 16 | no: groups?.[1], 17 | }, 18 | overwriteEpisode: true, 19 | }; 20 | }, 21 | }; 22 | export default hooks; 23 | -------------------------------------------------------------------------------- /packages/web/src/assets/banner-landscape.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/src/assets/banner-landscape.jpeg -------------------------------------------------------------------------------- /packages/web/src/assets/banner-portrait.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/src/assets/banner-portrait.jpeg -------------------------------------------------------------------------------- /packages/web/src/assets/banner.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/src/assets/banner.jpeg -------------------------------------------------------------------------------- /packages/web/src/assets/blank-favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/src/assets/blank-favicon.png -------------------------------------------------------------------------------- /packages/web/src/assets/fonts/MapleMono-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/src/assets/fonts/MapleMono-Bold.woff2 -------------------------------------------------------------------------------- /packages/web/src/assets/fonts/MapleMono-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/src/assets/fonts/MapleMono-Italic.woff2 -------------------------------------------------------------------------------- /packages/web/src/assets/fonts/MapleMono-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/src/assets/fonts/MapleMono-Regular.woff2 -------------------------------------------------------------------------------- /packages/web/src/assets/live-poster-fallback.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/src/assets/live-poster-fallback.png -------------------------------------------------------------------------------- /packages/web/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | MinaPlay 3 | 4 | 5 | Layer 1 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /packages/web/src/assets/mxplayer-pro.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/src/assets/mxplayer-pro.webp -------------------------------------------------------------------------------- /packages/web/src/assets/mxplayer.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/src/assets/mxplayer.webp -------------------------------------------------------------------------------- /packages/web/src/assets/potplayer.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/src/assets/potplayer.webp -------------------------------------------------------------------------------- /packages/web/src/assets/vlc.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/web/src/components/app/AuthedRouterView.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /packages/web/src/components/app/ExpandableText.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /packages/web/src/components/app/MessagesContainer.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 32 | 33 | 37 | -------------------------------------------------------------------------------- /packages/web/src/components/app/NavSections.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /packages/web/src/components/app/NavTabs.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /packages/web/src/components/app/TimeAgo.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /packages/web/src/components/app/ZoomImg.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /packages/web/src/components/live/LiveMessage.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /packages/web/src/components/live/LiveNotifyMessage.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /packages/web/src/components/plugin/PluginParser.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/web/src/components/user/UserAvatar.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /packages/web/src/composables/use-async-task.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | 3 | export function useAsyncTask Promise, R = Awaited>>(task: T) { 4 | const pending = ref(false); 5 | const error = ref(); 6 | const data = ref(); 7 | 8 | const resolved: ((data: R) => any)[] = []; 9 | const onResolved = (handler: (data: R) => any) => { 10 | resolved.push(handler); 11 | }; 12 | 13 | const rejected: ((error: any) => any)[] = []; 14 | const onRejected = (handler: (error: any) => any) => { 15 | rejected.push(handler); 16 | }; 17 | 18 | const request = async (...args: Parameters) => { 19 | if (pending.value) { 20 | return; 21 | } 22 | 23 | error.value = undefined; 24 | data.value = undefined; 25 | 26 | pending.value = true; 27 | try { 28 | const response = await task(...args); 29 | data.value = response; 30 | for (const handler of resolved) { 31 | await handler?.(response); 32 | } 33 | } catch (e) { 34 | error.value = e; 35 | for (const handler of rejected) { 36 | await handler?.(e); 37 | } 38 | } finally { 39 | pending.value = false; 40 | } 41 | }; 42 | 43 | return { 44 | pending, 45 | error, 46 | data, 47 | onResolved, 48 | onRejected, 49 | request, 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /packages/web/src/composables/use-axios-request.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | import { AxiosError, AxiosResponse } from 'axios'; 3 | import { ApiError } from '@/api/interfaces/common.interface'; 4 | 5 | export function useAxiosRequest< 6 | T extends (...args: any[]) => Promise, 7 | R = Awaited>['data'], 8 | >(api: T) { 9 | const pending = ref(false); 10 | const error = ref | Error>(); 11 | const data = ref(); 12 | 13 | const resolved: ((data: R) => any)[] = []; 14 | const onResolved = (handler: (data: R) => any) => { 15 | resolved.push(handler); 16 | }; 17 | 18 | const rejected: ((error: AxiosError | Error) => any)[] = []; 19 | const onRejected = (handler: (error: AxiosError | Error) => any) => { 20 | rejected.push(handler); 21 | }; 22 | 23 | const request = async (...args: Parameters) => { 24 | if (pending.value) { 25 | return; 26 | } 27 | 28 | error.value = undefined; 29 | data.value = undefined; 30 | 31 | pending.value = true; 32 | try { 33 | const response = await api(...args); 34 | data.value = response.data; 35 | for (const handler of resolved) { 36 | await handler?.(response.data); 37 | } 38 | } catch (e) { 39 | error.value = e as AxiosError | Error; 40 | for (const handler of rejected) { 41 | await handler?.(error.value); 42 | } 43 | } finally { 44 | pending.value = false; 45 | } 46 | }; 47 | 48 | return { 49 | pending, 50 | error, 51 | data, 52 | onResolved, 53 | onRejected, 54 | request, 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /packages/web/src/css/highlightjs.sass: -------------------------------------------------------------------------------- 1 | @use 'sass:meta' 2 | 3 | html[data-code-theme='hljs-dark'] 4 | @include meta.load-css('highlight.js/styles/github-dark-dimmed.css') 5 | 6 | html[data-code-theme="hljs-light"] 7 | @include meta.load-css("highlight.js/styles/a11y-light.css") 8 | 9 | code[class*="hljs"] 10 | height: 100% 11 | -------------------------------------------------------------------------------- /packages/web/src/css/main.sass: -------------------------------------------------------------------------------- 1 | @use 'vuetify' with ($color-pack: false) 2 | 3 | html 4 | overflow-y: hidden 5 | 6 | .page-height 7 | height: calc(100vh - 64px) 8 | 9 | .scrollable-container 10 | flex-grow: 1 11 | overflow-y: auto 12 | height: 0 13 | 14 | .plyr 15 | height: 100% 16 | 17 | .pointer-events-none 18 | pointer-events: none 19 | 20 | .pointer-events-initial 21 | pointer-events: initial 22 | -------------------------------------------------------------------------------- /packages/web/src/lang/index.ts: -------------------------------------------------------------------------------- 1 | import { createI18n } from 'vue-i18n'; 2 | 3 | import enLocale from './en'; 4 | import zhLocale from './zh'; 5 | 6 | export type MessageSchema = typeof enLocale; 7 | 8 | export const LANGUAGES = { 9 | 'en-US': enLocale, 10 | 'zh-CN': zhLocale, 11 | }; 12 | 13 | export type MessageLocale = keyof typeof LANGUAGES; 14 | 15 | export default createI18n<[MessageSchema], 'en-US' | 'zh-CN'>({ 16 | legacy: false, 17 | locale: 'en-US', 18 | fallbackLocale: 'en-US', 19 | messages: LANGUAGES, 20 | }); 21 | -------------------------------------------------------------------------------- /packages/web/src/layouts/home/Layout.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 12 | -------------------------------------------------------------------------------- /packages/web/src/main.ts: -------------------------------------------------------------------------------- 1 | import { registerPlugins } from '@/plugins'; 2 | import { createApp } from 'vue'; 3 | import App from './App.vue'; 4 | import '@/plugins/monaco'; 5 | import 'unfonts.css'; 6 | import '@/css/main.sass'; 7 | import '@/css/highlightjs.sass'; 8 | import { registerSW } from 'virtual:pwa-register'; 9 | 10 | registerSW({ immediate: true }); 11 | 12 | const app = createApp(App); 13 | registerPlugins(app); 14 | app.mount('#app'); 15 | -------------------------------------------------------------------------------- /packages/web/src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import vuetify from './vuetify'; 2 | import pinia from '../store'; 3 | import router from '../router'; 4 | import i18n from '../lang'; 5 | import type { App } from 'vue'; 6 | 7 | export function registerPlugins(app: App) { 8 | app.use(vuetify).use(router).use(pinia).use(i18n); 9 | } 10 | -------------------------------------------------------------------------------- /packages/web/src/plugins/monaco.ts: -------------------------------------------------------------------------------- 1 | import * as monaco from 'monaco-editor'; 2 | import { editor } from 'monaco-editor'; 3 | 4 | self.MonacoEnvironment = { 5 | getWorker: async function (_, label) { 6 | let worker: { default: any }; 7 | 8 | switch (label) { 9 | case 'json': 10 | worker = await import('monaco-editor/esm/vs/language/json/json.worker?worker'); 11 | break; 12 | case 'typescript': 13 | case 'javascript': 14 | worker = await import('monaco-editor/esm/vs/language/typescript/ts.worker?worker'); 15 | break; 16 | default: 17 | worker = await import('monaco-editor/esm/vs/editor/editor.worker?worker'); 18 | } 19 | 20 | return new worker.default(); 21 | }, 22 | }; 23 | 24 | monaco.editor.addEditorAction({ 25 | id: 'Find Definition', 26 | label: 'Find Definition', 27 | keybindings: [monaco.KeyMod.Alt | monaco.KeyCode.F12], 28 | precondition: undefined, 29 | keybindingContext: undefined, 30 | run(editor: editor.ICodeEditor, ...args) { 31 | editor.trigger('source', 'editor.action.peekDefinition', args); 32 | }, 33 | }); 34 | monaco.editor.onDidCreateEditor((editor) => { 35 | editor.onMouseDown((e) => { 36 | if (e.event.ctrlKey && e.event.leftButton) { 37 | e.event.preventDefault(); 38 | editor.trigger('source', 'editor.action.peekDefinition', null); 39 | } 40 | }); 41 | }); 42 | monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({ 43 | ...monaco.languages.typescript.javascriptDefaults.getDiagnosticsOptions(), 44 | noSemanticValidation: false, 45 | noSuggestionDiagnostics: false, 46 | noSyntaxValidation: false, 47 | }); 48 | monaco.languages.typescript.javascriptDefaults.setCompilerOptions({ 49 | ...monaco.languages.typescript.javascriptDefaults.getCompilerOptions(), 50 | checkJs: true, 51 | }); 52 | -------------------------------------------------------------------------------- /packages/web/src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import 'vuetify/styles'; 2 | 3 | import { createVuetify } from 'vuetify'; 4 | import { aliases, mdi } from 'vuetify/iconsets/mdi-svg'; 5 | 6 | const light = { 7 | dark: false, 8 | colors: { 9 | background: '#ffffff', 10 | surface: '#ffffff', 11 | primary: '#1976d2', 12 | secondary: '#f50057', 13 | error: '#d32f2f', 14 | warning: '#ed6c02', 15 | info: '#0288d1', 16 | success: '#2e7d32', 17 | chat: '#e5e5e5', 18 | }, 19 | }; 20 | 21 | const dark = { 22 | dark: true, 23 | colors: { 24 | background: '#18181b', 25 | surface: '#18181b', 26 | 'on-background': '#d1d5db', 27 | 'on-surface': '#d1d5db', 28 | primary: '#1976d2', 29 | secondary: '#f50057', 30 | error: '#d32f2f', 31 | warning: '#ffa726', 32 | info: '#29b6f6', 33 | success: '#66bb6a', 34 | chat: '#323232', 35 | }, 36 | }; 37 | 38 | export default createVuetify({ 39 | icons: { 40 | sets: { mdi }, 41 | aliases, 42 | defaultSet: 'mdi', 43 | }, 44 | theme: { 45 | defaultTheme: 'light', 46 | themes: { 47 | light, 48 | dark, 49 | }, 50 | variations: { 51 | colors: ['primary', 'secondary', 'error', 'warning', 'success', 'info'], 52 | lighten: 2, 53 | darken: 2, 54 | }, 55 | }, 56 | }); 57 | -------------------------------------------------------------------------------- /packages/web/src/shims-danmu.js.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'danmu.js' { 2 | import DanmuJs from 'danmu.js/dist/types/src'; 3 | export * from 'danmu.js/dist/types/src'; 4 | export default DanmuJs; 5 | } 6 | -------------------------------------------------------------------------------- /packages/web/src/shims-vue.d.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepsyn/minaplay/d52eccb042e99cdbc60b872ca5b02d2408faf490/packages/web/src/shims-vue.d.ts -------------------------------------------------------------------------------- /packages/web/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createPinia } from 'pinia'; 2 | 3 | export default createPinia(); 4 | -------------------------------------------------------------------------------- /packages/web/src/store/layout.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { computed, ref } from 'vue'; 3 | import { useDisplay, useTheme } from 'vuetify'; 4 | 5 | export const useLayoutStore = defineStore('layout', () => { 6 | const display = useDisplay(); 7 | const theme = useTheme(); 8 | 9 | const navDrawer = ref(display.mdAndUp.value); 10 | const uploadDrawer = ref(false); 11 | const pluginConsoleSheet = ref(false); 12 | const pluginConsoleFullscreen = ref(false); 13 | const notificationWindow = ref(false); 14 | 15 | const darkMode = computed(() => theme.global.current.value.dark); 16 | const toggleDarkMode = (darkMode: boolean) => { 17 | theme.global.name.value = darkMode ? 'dark' : 'light'; 18 | }; 19 | 20 | return { 21 | navDrawer, 22 | uploadDrawer, 23 | pluginConsoleSheet, 24 | pluginConsoleFullscreen, 25 | notificationWindow, 26 | darkMode, 27 | toggleDarkMode, 28 | }; 29 | }); 30 | -------------------------------------------------------------------------------- /packages/web/src/store/settings.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { LANGUAGES, MessageLocale } from '@/lang'; 3 | import { ref, watch } from 'vue'; 4 | 5 | export interface AppSettings { 6 | theme: 'dark' | 'light' | 'auto'; 7 | locale: MessageLocale; 8 | 9 | showSubtitle: boolean; 10 | showDanmaku: boolean; 11 | autoJoinVoice: boolean; 12 | autoContinue: boolean; 13 | plates: Array<'series-update' | 'history' | 'media-update' | string>; 14 | } 15 | 16 | export const useSettingsStore = defineStore('settings', () => { 17 | let localSettings = undefined; 18 | try { 19 | localSettings = JSON.parse(localStorage.getItem('minaplay-settings') as string); 20 | } catch {} 21 | 22 | const settings = ref( 23 | Object.assign( 24 | { 25 | theme: 'auto', 26 | locale: Object.keys(LANGUAGES).find((value) => value === navigator?.language) ?? LANGUAGES['zh-CN'], 27 | showSubtitle: true, 28 | showDanmaku: true, 29 | autoJoinVoice: false, 30 | autoContinue: true, 31 | plates: ['series-update', 'history', 'media-update'], 32 | }, 33 | localSettings, 34 | ), 35 | ); 36 | 37 | watch( 38 | () => settings.value, 39 | () => { 40 | localStorage.setItem('minaplay-settings', JSON.stringify(settings.value)); 41 | }, 42 | { 43 | deep: true, 44 | }, 45 | ); 46 | 47 | return { 48 | settings, 49 | }; 50 | }); 51 | -------------------------------------------------------------------------------- /packages/web/src/store/toast.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref } from 'vue'; 3 | 4 | export interface AppMessage { 5 | id: number; 6 | content: string; 7 | type: 'error' | 'warning' | 'success' | 'info'; 8 | timeout: number; 9 | } 10 | 11 | export const useToastStore = defineStore('toast', () => { 12 | let counter = 0; 13 | const messages = ref([]); 14 | const closeToast = (id: number) => { 15 | messages.value = messages.value.filter((v) => v.id !== id); 16 | }; 17 | const toast = ( 18 | content: string, 19 | type: 'error' | 'warning' | 'success' | 'info' = 'success', 20 | timeout: number = 3000, 21 | ) => { 22 | const message = { 23 | id: counter++, 24 | content, 25 | type, 26 | timeout, 27 | }; 28 | messages.value.push(message); 29 | if (timeout > 0) { 30 | setTimeout(() => { 31 | closeToast(message.id); 32 | }, timeout); 33 | } 34 | }; 35 | const toastSuccess = (content: string, timeout?: number) => toast(content, 'success', timeout); 36 | const toastWarning = (content: string, timeout?: number) => toast(content, 'warning', timeout); 37 | const toastError = (content: string, timeout?: number) => toast(content, 'error', timeout); 38 | const toastInfo = (content: string, timeout?: number) => toast(content, 'info', timeout); 39 | 40 | return { 41 | messages, 42 | toast, 43 | closeToast, 44 | toastSuccess, 45 | toastWarning, 46 | toastError, 47 | toastInfo, 48 | }; 49 | }); 50 | -------------------------------------------------------------------------------- /packages/web/src/views/About.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/web/src/views/Resource.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 32 | -------------------------------------------------------------------------------- /packages/web/src/views/Setting.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /packages/web/src/views/error/NoPermission.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /packages/web/src/views/error/NoPlates.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /packages/web/src/views/error/NotFound.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /packages/web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue'; 5 | const component: DefineComponent<{}, {}, any>; 6 | export default component; 7 | } 8 | 9 | interface ImportMetaEnv { 10 | readonly VITE_API_HOST: string; 11 | } 12 | 13 | interface ImportMeta { 14 | readonly env: ImportMetaEnv; 15 | } 16 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "moduleResolution": "Node", 8 | "strict": true, 9 | "jsx": "preserve", 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "lib": [ 14 | "ESNext", 15 | "DOM" 16 | ], 17 | "skipLibCheck": true, 18 | "noEmit": true, 19 | "paths": { 20 | "@/*": [ 21 | "src/*" 22 | ] 23 | }, 24 | "types": [ 25 | "vite-plugin-pwa/client", 26 | "node" 27 | ] 28 | }, 29 | "include": [ 30 | "src/**/*.ts", 31 | "src/**/*.d.ts", 32 | "src/**/*.tsx", 33 | "src/**/*.vue" 34 | ], 35 | "exclude": [ 36 | "node_modules", 37 | "src/**/templates" 38 | ], 39 | "references": [ 40 | { 41 | "path": "./tsconfig.node.json" 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /packages/web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": [ 9 | "vite.config.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | devDependencies: 11 | prettier: 12 | specifier: ^2.8.7 13 | version: 2.8.8 14 | tsconfig-paths: 15 | specifier: ^4.2.0 16 | version: 4.2.0 17 | 18 | packages: 19 | 20 | json5@2.2.3: 21 | resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} 22 | engines: {node: '>=6'} 23 | hasBin: true 24 | 25 | minimist@1.2.8: 26 | resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} 27 | 28 | prettier@2.8.8: 29 | resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} 30 | engines: {node: '>=10.13.0'} 31 | hasBin: true 32 | 33 | strip-bom@3.0.0: 34 | resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} 35 | engines: {node: '>=4'} 36 | 37 | tsconfig-paths@4.2.0: 38 | resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} 39 | engines: {node: '>=6'} 40 | 41 | snapshots: 42 | 43 | json5@2.2.3: {} 44 | 45 | minimist@1.2.8: {} 46 | 47 | prettier@2.8.8: {} 48 | 49 | strip-bom@3.0.0: {} 50 | 51 | tsconfig-paths@4.2.0: 52 | dependencies: 53 | json5: 2.2.3 54 | minimist: 1.2.8 55 | strip-bom: 3.0.0 56 | --------------------------------------------------------------------------------