From 3f3e6b423574ae1410e473a2ce3df923fd332227 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 20 Apr 2026 08:39:32 +0200 Subject: [PATCH 001/134] Move App into subfolder --- .github/workflows/CD.yml | 25 +++++++++++------- .github/workflows/CI.yml | 5 +++- .gitignore | 4 +-- .vscode/extensions.json | 7 +++++ .iconify.ini => app/.iconify.ini | 0 LICENSE.txt => app/LICENSE.txt | 0 {changelogs => app/changelogs}/v1.0.0.md | 0 {changelogs => app/changelogs}/v1.0.1.md | 0 {changelogs => app/changelogs}/v1.1.0.md | 0 {changelogs => app/changelogs}/v1.2.0.md | 0 {changelogs => app/changelogs}/v1.3.0.md | 0 i18n.gen.cjs => app/i18n.gen.cjs | 0 index.html => app/index.html | 0 package-lock.json => app/package-lock.json | 0 package.json => app/package.json | 0 {public => app/public}/128x128.png | Bin {public => app/public}/aniworld-dark.svg | 0 {public => app/public}/aniworld.svg | 0 .../public}/choose/aniworld-dark.svg | 0 .../public}/choose/aniworld-light.svg | 0 {public => app/public}/choose/sto-dark.svg | 0 {public => app/public}/choose/sto-light.svg | 0 {public => app/public}/sto.svg | 0 {public => app/public}/sync/aniworld-dark.svg | 0 .../public}/sync/aniworld-light.svg | 0 {public => app/public}/sync/sto-dark.svg | 0 {public => app/public}/sync/sto-light.svg | 0 {public => app/public}/tauri.svg | 0 {src-tauri => app/src-tauri}/.gitignore | 0 {src-tauri => app/src-tauri}/2 | 0 {src-tauri => app/src-tauri}/Cargo.lock | 0 {src-tauri => app/src-tauri}/Cargo.toml | 0 {src-tauri => app/src-tauri}/build.rs | 0 .../src-tauri}/capabilities/default.json | 0 .../src-tauri}/capabilities/desktop.json | 0 .../src-tauri}/icons/128x128.png | Bin .../src-tauri}/icons/128x128@2x.png | Bin {src-tauri => app/src-tauri}/icons/32x32.png | Bin {src-tauri => app/src-tauri}/icons/64x64.png | Bin .../src-tauri}/icons/Square107x107Logo.png | Bin .../src-tauri}/icons/Square142x142Logo.png | Bin .../src-tauri}/icons/Square150x150Logo.png | Bin .../src-tauri}/icons/Square284x284Logo.png | Bin .../src-tauri}/icons/Square30x30Logo.png | Bin .../src-tauri}/icons/Square310x310Logo.png | Bin .../src-tauri}/icons/Square44x44Logo.png | Bin .../src-tauri}/icons/Square71x71Logo.png | Bin .../src-tauri}/icons/Square89x89Logo.png | Bin .../src-tauri}/icons/StoreLogo.png | Bin {src-tauri => app/src-tauri}/icons/icon.icns | Bin {src-tauri => app/src-tauri}/icons/icon.ico | Bin {src-tauri => app/src-tauri}/icons/icon.png | Bin .../src-tauri}/icons/original.svg | 0 {src-tauri => app/src-tauri}/src/lib.rs | 0 {src-tauri => app/src-tauri}/src/main.rs | 0 {src-tauri => app/src-tauri}/tauri.conf.json | 0 {src => app/src}/config.ts | 0 .../src}/contracts/changelog.contract.ts | 0 .../src}/contracts/episode.contract.ts | 0 {src => app/src}/contracts/fetch.contract.ts | 0 {src => app/src}/contracts/genre.contract.ts | 0 {src => app/src}/contracts/i18n.contract.ts | 0 {src => app/src}/contracts/list.contract.ts | 0 .../src}/contracts/provider.contract.ts | 0 {src => app/src}/contracts/report.contract.ts | 0 {src => app/src}/contracts/season.contract.ts | 0 {src => app/src}/contracts/series.contract.ts | 0 .../src}/contracts/settings.contract.ts | 0 .../contracts/standalone/metadata.contract.ts | 0 .../contracts/standalone/user.contract.ts | 0 {src => app/src}/contracts/update.contract.ts | 0 {src => app/src}/contracts/user.contract.ts | 0 .../src}/contracts/watchlist.contract.ts | 0 .../src}/contracts/watchtime.contract.ts | 0 .../src}/controls/ChangelogControl.model.ts | 0 .../src}/controls/ChangelogControl.vue | 0 {src => app/src}/controls/ColorPicker.vue | 0 .../src}/controls/ConfirmControl.model.ts | 0 {src => app/src}/controls/ConfirmControl.vue | 0 .../src}/controls/DetailControl.model.ts | 0 {src => app/src}/controls/DetailControl.vue | 0 {src => app/src}/controls/HLSPlayer.model.ts | 0 {src => app/src}/controls/HLSPlayer.vue | 0 {src => app/src}/controls/ImageHash.vue | 0 .../src}/controls/InfoControl.model.ts | 0 {src => app/src}/controls/InfoControl.vue | 4 +-- {src => app/src}/controls/InfoToast.model.ts | 0 {src => app/src}/controls/InfoToast.vue | 0 {src => app/src}/controls/ListHash.vue | 0 .../src}/controls/PrefControl.model.ts | 0 {src => app/src}/controls/PrefControl.vue | 0 .../src}/controls/ProfileDialog.model.ts | 0 {src => app/src}/controls/ProfileDialog.vue | 0 .../controls/ProfileSetupControl.model.ts | 0 .../src}/controls/ProfileSetupControl.vue | 0 .../src}/controls/ProgressToast.model.ts | 0 {src => app/src}/controls/ProgressToast.vue | 0 .../src}/controls/ReportControl.model.ts | 0 {src => app/src}/controls/ReportControl.vue | 0 {src => app/src}/controls/Text.model.ts | 0 {src => app/src}/controls/Text.vue | 0 {src => app/src}/controls/ToSControl.model.ts | 0 {src => app/src}/controls/ToSControl.vue | 4 +-- {src => app/src}/controls/ToastContainer.vue | 0 .../src}/controls/UpdateControl.model.ts | 0 {src => app/src}/controls/UpdateControl.vue | 0 .../src}/icons/FlowbiteGithubSolid.vue | 0 {src => app/src}/icons/LucideArrowLeft.vue | 0 {src => app/src}/icons/LucideArrowRight.vue | 0 {src => app/src}/icons/LucideCheck.vue | 0 {src => app/src}/icons/LucideCloudSync.vue | 0 {src => app/src}/icons/LucideEdit.vue | 0 .../src}/icons/LucideEllipsisVertical.vue | 0 {src => app/src}/icons/LucideEye.vue | 0 {src => app/src}/icons/LucideEyeOff.vue | 0 {src => app/src}/icons/LucideFolder.vue | 0 {src => app/src}/icons/LucideHeart.vue | 0 {src => app/src}/icons/LucideHistory.vue | 0 {src => app/src}/icons/LucideInfo.vue | 0 {src => app/src}/icons/LucideListVideo.vue | 0 {src => app/src}/icons/LucideMaximize.vue | 0 {src => app/src}/icons/LucideMinimize.vue | 0 {src => app/src}/icons/LucideNewspaper.vue | 0 {src => app/src}/icons/LucidePalette.vue | 0 {src => app/src}/icons/LucidePause.vue | 0 {src => app/src}/icons/LucidePlay.vue | 0 {src => app/src}/icons/LucidePlus.vue | 0 {src => app/src}/icons/LucidePopcorn.vue | 0 {src => app/src}/icons/LucideSearch.vue | 0 {src => app/src}/icons/LucideSettings.vue | 0 {src => app/src}/icons/LucideTimerReset.vue | 0 {src => app/src}/icons/LucideTrash.vue | 0 {src => app/src}/icons/LucideX.vue | 0 .../src}/icons/SvgSpinnersBlocksWave.vue | 0 .../src}/langs/control/DetailControl.de.json | 0 .../src}/langs/control/DetailControl.en.json | 0 .../src}/langs/control/HLSPlayer.de.json | 0 .../src}/langs/control/HLSPlayer.en.json | 0 .../src}/langs/control/InfoControl.de.json | 0 .../src}/langs/control/InfoControl.en.json | 0 .../src}/langs/control/PrefControl.de.json | 0 .../src}/langs/control/PrefControl.en.json | 0 .../langs/control/ProfileSetupControl.de.json | 0 .../langs/control/ProfileSetupControl.en.json | 0 .../src}/langs/control/ReportControl.de.json | 0 .../src}/langs/control/ReportControl.en.json | 0 .../src}/langs/control/UpdateControl.de.json | 0 .../src}/langs/control/UpdateControl.en.json | 0 .../src}/langs/other/EpisodeLanguage.en.json | 0 {src => app/src}/langs/other/Genres.de.json | 0 {src => app/src}/langs/other/Genres.en.json | 0 {src => app/src}/langs/views/ListView.de.json | 0 {src => app/src}/langs/views/ListView.en.json | 0 .../src}/langs/views/PlayerView.de.json | 0 .../src}/langs/views/PlayerView.en.json | 0 .../src}/langs/views/ProviderView.de.json | 0 .../src}/langs/views/ProviderView.en.json | 0 .../src}/langs/views/SeriesSyncView.de.json | 0 .../src}/langs/views/SeriesSyncView.en.json | 0 .../src}/langs/views/SettingsView.de.json | 0 .../src}/langs/views/SettingsView.en.json | 0 .../src}/langs/views/StreamView.de.json | 0 .../src}/langs/views/StreamView.en.json | 0 .../src}/langs/views/StreamsView.de.json | 0 .../src}/langs/views/StreamsView.en.json | 0 {src => app/src}/langs/views/SyncView.de.json | 0 {src => app/src}/langs/views/SyncView.en.json | 0 .../src}/langs/views/WatchlistView.de.json | 0 .../src}/langs/views/WatchlistView.en.json | 0 {src => app/src}/main.css | 0 {src => app/src}/main.ts | 0 {src => app/src}/models/episode.model.ts | 0 {src => app/src}/models/genre.model.ts | 0 {src => app/src}/models/list.model.ts | 0 {src => app/src}/models/profile.model.ts | 0 {src => app/src}/models/season.model.ts | 0 {src => app/src}/models/series.model.ts | 0 {src => app/src}/models/watchtime.model.ts | 0 .../src}/providers/aniworld/fetcher.ts | 0 {src => app/src}/providers/aniworld/index.ts | 0 .../src}/providers/aniworld/provider.ts | 0 {src => app/src}/providers/default.ts | 0 {src => app/src}/providers/sto/fetcher.ts | 0 {src => app/src}/providers/sto/index.ts | 0 {src => app/src}/providers/sto/provider.ts | 0 .../src}/services/shared/changelog.service.ts | 0 .../src}/services/shared/declaration.ts | 0 .../src}/services/shared/i18n.service.ts | 0 .../src}/services/shared/report.service.ts | 0 .../standalone/db/metadata.service.ts | 0 .../services/standalone/db/user.service.ts | 0 .../services/standalone/episode.service.ts | 0 .../src}/services/standalone/fetch.service.ts | 0 .../src}/services/standalone/genre.service.ts | 0 .../src}/services/standalone/list.service.ts | 0 .../services/standalone/provider.service.ts | 0 .../services/standalone/season.service.ts | 0 .../services/standalone/series.service.ts | 0 .../services/standalone/settings.service.ts | 0 .../services/standalone/update.service.ts | 0 .../src}/services/standalone/user.service.ts | 0 .../src}/services/standalone/utils/db.ts | 0 .../services/standalone/watchlist.service.ts | 0 .../services/standalone/watchtime.service.ts | 0 {src => app/src}/sources/doodstream.ts | 0 {src => app/src}/sources/filemoon.ts | 0 {src => app/src}/sources/index.ts | 0 {src => app/src}/sources/loadx.ts | 0 {src => app/src}/sources/luluvdo.ts | 0 {src => app/src}/sources/speedfiles.ts | 0 {src => app/src}/sources/vidmoly.ts | 0 {src => app/src}/sources/vidoza.ts | 0 {src => app/src}/sources/voe.ts | 0 {src => app/src}/utils/array.ts | 0 {src => app/src}/utils/hash.ts | 0 {src => app/src}/utils/hls-tauri-bridge.ts | 0 {src => app/src}/utils/http.ts | 0 {src => app/src}/utils/markdown.ts | 0 {src => app/src}/utils/string.ts | 0 {src => app/src}/utils/throttle.ts | 0 {src => app/src}/views/ListView.model.ts | 0 {src => app/src}/views/ListView.vue | 0 {src => app/src}/views/PlayerView.model.ts | 0 {src => app/src}/views/PlayerView.vue | 0 {src => app/src}/views/ProfileView.model.ts | 0 {src => app/src}/views/ProfileView.vue | 0 {src => app/src}/views/ProviderView.model.ts | 0 {src => app/src}/views/ProviderView.vue | 0 .../src}/views/SeriesSyncView.model.ts | 0 {src => app/src}/views/SeriesSyncView.vue | 0 {src => app/src}/views/SettingsView.model.ts | 0 {src => app/src}/views/SettingsView.vue | 0 {src => app/src}/views/StreamView.model.ts | 0 {src => app/src}/views/StreamView.vue | 0 {src => app/src}/views/StreamsView.model.ts | 0 {src => app/src}/views/StreamsView.vue | 0 {src => app/src}/views/SyncView.model.ts | 0 {src => app/src}/views/SyncView.vue | 0 {src => app/src}/views/WatchlistView.model.ts | 0 {src => app/src}/views/WatchlistView.vue | 0 {src => app/src}/vite-env.d.ts | 0 tsconfig.json => app/tsconfig.json | 0 tsconfig.node.json => app/tsconfig.node.json | 0 vite.config.ts => app/vite.config.ts | 0 244 files changed, 32 insertions(+), 17 deletions(-) create mode 100644 .vscode/extensions.json rename .iconify.ini => app/.iconify.ini (100%) rename LICENSE.txt => app/LICENSE.txt (100%) rename {changelogs => app/changelogs}/v1.0.0.md (100%) rename {changelogs => app/changelogs}/v1.0.1.md (100%) rename {changelogs => app/changelogs}/v1.1.0.md (100%) rename {changelogs => app/changelogs}/v1.2.0.md (100%) rename {changelogs => app/changelogs}/v1.3.0.md (100%) rename i18n.gen.cjs => app/i18n.gen.cjs (100%) rename index.html => app/index.html (100%) rename package-lock.json => app/package-lock.json (100%) rename package.json => app/package.json (100%) rename {public => app/public}/128x128.png (100%) rename {public => app/public}/aniworld-dark.svg (100%) rename {public => app/public}/aniworld.svg (100%) rename {public => app/public}/choose/aniworld-dark.svg (100%) rename {public => app/public}/choose/aniworld-light.svg (100%) rename {public => app/public}/choose/sto-dark.svg (100%) rename {public => app/public}/choose/sto-light.svg (100%) rename {public => app/public}/sto.svg (100%) rename {public => app/public}/sync/aniworld-dark.svg (100%) rename {public => app/public}/sync/aniworld-light.svg (100%) rename {public => app/public}/sync/sto-dark.svg (100%) rename {public => app/public}/sync/sto-light.svg (100%) rename {public => app/public}/tauri.svg (100%) rename {src-tauri => app/src-tauri}/.gitignore (100%) rename {src-tauri => app/src-tauri}/2 (100%) rename {src-tauri => app/src-tauri}/Cargo.lock (100%) rename {src-tauri => app/src-tauri}/Cargo.toml (100%) rename {src-tauri => app/src-tauri}/build.rs (100%) rename {src-tauri => app/src-tauri}/capabilities/default.json (100%) rename {src-tauri => app/src-tauri}/capabilities/desktop.json (100%) rename {src-tauri => app/src-tauri}/icons/128x128.png (100%) rename {src-tauri => app/src-tauri}/icons/128x128@2x.png (100%) rename {src-tauri => app/src-tauri}/icons/32x32.png (100%) rename {src-tauri => app/src-tauri}/icons/64x64.png (100%) rename {src-tauri => app/src-tauri}/icons/Square107x107Logo.png (100%) rename {src-tauri => app/src-tauri}/icons/Square142x142Logo.png (100%) rename {src-tauri => app/src-tauri}/icons/Square150x150Logo.png (100%) rename {src-tauri => app/src-tauri}/icons/Square284x284Logo.png (100%) rename {src-tauri => app/src-tauri}/icons/Square30x30Logo.png (100%) rename {src-tauri => app/src-tauri}/icons/Square310x310Logo.png (100%) rename {src-tauri => app/src-tauri}/icons/Square44x44Logo.png (100%) rename {src-tauri => app/src-tauri}/icons/Square71x71Logo.png (100%) rename {src-tauri => app/src-tauri}/icons/Square89x89Logo.png (100%) rename {src-tauri => app/src-tauri}/icons/StoreLogo.png (100%) rename {src-tauri => app/src-tauri}/icons/icon.icns (100%) rename {src-tauri => app/src-tauri}/icons/icon.ico (100%) rename {src-tauri => app/src-tauri}/icons/icon.png (100%) rename {src-tauri => app/src-tauri}/icons/original.svg (100%) rename {src-tauri => app/src-tauri}/src/lib.rs (100%) rename {src-tauri => app/src-tauri}/src/main.rs (100%) rename {src-tauri => app/src-tauri}/tauri.conf.json (100%) rename {src => app/src}/config.ts (100%) rename {src => app/src}/contracts/changelog.contract.ts (100%) rename {src => app/src}/contracts/episode.contract.ts (100%) rename {src => app/src}/contracts/fetch.contract.ts (100%) rename {src => app/src}/contracts/genre.contract.ts (100%) rename {src => app/src}/contracts/i18n.contract.ts (100%) rename {src => app/src}/contracts/list.contract.ts (100%) rename {src => app/src}/contracts/provider.contract.ts (100%) rename {src => app/src}/contracts/report.contract.ts (100%) rename {src => app/src}/contracts/season.contract.ts (100%) rename {src => app/src}/contracts/series.contract.ts (100%) rename {src => app/src}/contracts/settings.contract.ts (100%) rename {src => app/src}/contracts/standalone/metadata.contract.ts (100%) rename {src => app/src}/contracts/standalone/user.contract.ts (100%) rename {src => app/src}/contracts/update.contract.ts (100%) rename {src => app/src}/contracts/user.contract.ts (100%) rename {src => app/src}/contracts/watchlist.contract.ts (100%) rename {src => app/src}/contracts/watchtime.contract.ts (100%) rename {src => app/src}/controls/ChangelogControl.model.ts (100%) rename {src => app/src}/controls/ChangelogControl.vue (100%) rename {src => app/src}/controls/ColorPicker.vue (100%) rename {src => app/src}/controls/ConfirmControl.model.ts (100%) rename {src => app/src}/controls/ConfirmControl.vue (100%) rename {src => app/src}/controls/DetailControl.model.ts (100%) rename {src => app/src}/controls/DetailControl.vue (100%) rename {src => app/src}/controls/HLSPlayer.model.ts (100%) rename {src => app/src}/controls/HLSPlayer.vue (100%) rename {src => app/src}/controls/ImageHash.vue (100%) rename {src => app/src}/controls/InfoControl.model.ts (100%) rename {src => app/src}/controls/InfoControl.vue (98%) rename {src => app/src}/controls/InfoToast.model.ts (100%) rename {src => app/src}/controls/InfoToast.vue (100%) rename {src => app/src}/controls/ListHash.vue (100%) rename {src => app/src}/controls/PrefControl.model.ts (100%) rename {src => app/src}/controls/PrefControl.vue (100%) rename {src => app/src}/controls/ProfileDialog.model.ts (100%) rename {src => app/src}/controls/ProfileDialog.vue (100%) rename {src => app/src}/controls/ProfileSetupControl.model.ts (100%) rename {src => app/src}/controls/ProfileSetupControl.vue (100%) rename {src => app/src}/controls/ProgressToast.model.ts (100%) rename {src => app/src}/controls/ProgressToast.vue (100%) rename {src => app/src}/controls/ReportControl.model.ts (100%) rename {src => app/src}/controls/ReportControl.vue (100%) rename {src => app/src}/controls/Text.model.ts (100%) rename {src => app/src}/controls/Text.vue (100%) rename {src => app/src}/controls/ToSControl.model.ts (100%) rename {src => app/src}/controls/ToSControl.vue (95%) rename {src => app/src}/controls/ToastContainer.vue (100%) rename {src => app/src}/controls/UpdateControl.model.ts (100%) rename {src => app/src}/controls/UpdateControl.vue (100%) rename {src => app/src}/icons/FlowbiteGithubSolid.vue (100%) rename {src => app/src}/icons/LucideArrowLeft.vue (100%) rename {src => app/src}/icons/LucideArrowRight.vue (100%) rename {src => app/src}/icons/LucideCheck.vue (100%) rename {src => app/src}/icons/LucideCloudSync.vue (100%) rename {src => app/src}/icons/LucideEdit.vue (100%) rename {src => app/src}/icons/LucideEllipsisVertical.vue (100%) rename {src => app/src}/icons/LucideEye.vue (100%) rename {src => app/src}/icons/LucideEyeOff.vue (100%) rename {src => app/src}/icons/LucideFolder.vue (100%) rename {src => app/src}/icons/LucideHeart.vue (100%) rename {src => app/src}/icons/LucideHistory.vue (100%) rename {src => app/src}/icons/LucideInfo.vue (100%) rename {src => app/src}/icons/LucideListVideo.vue (100%) rename {src => app/src}/icons/LucideMaximize.vue (100%) rename {src => app/src}/icons/LucideMinimize.vue (100%) rename {src => app/src}/icons/LucideNewspaper.vue (100%) rename {src => app/src}/icons/LucidePalette.vue (100%) rename {src => app/src}/icons/LucidePause.vue (100%) rename {src => app/src}/icons/LucidePlay.vue (100%) rename {src => app/src}/icons/LucidePlus.vue (100%) rename {src => app/src}/icons/LucidePopcorn.vue (100%) rename {src => app/src}/icons/LucideSearch.vue (100%) rename {src => app/src}/icons/LucideSettings.vue (100%) rename {src => app/src}/icons/LucideTimerReset.vue (100%) rename {src => app/src}/icons/LucideTrash.vue (100%) rename {src => app/src}/icons/LucideX.vue (100%) rename {src => app/src}/icons/SvgSpinnersBlocksWave.vue (100%) rename {src => app/src}/langs/control/DetailControl.de.json (100%) rename {src => app/src}/langs/control/DetailControl.en.json (100%) rename {src => app/src}/langs/control/HLSPlayer.de.json (100%) rename {src => app/src}/langs/control/HLSPlayer.en.json (100%) rename {src => app/src}/langs/control/InfoControl.de.json (100%) rename {src => app/src}/langs/control/InfoControl.en.json (100%) rename {src => app/src}/langs/control/PrefControl.de.json (100%) rename {src => app/src}/langs/control/PrefControl.en.json (100%) rename {src => app/src}/langs/control/ProfileSetupControl.de.json (100%) rename {src => app/src}/langs/control/ProfileSetupControl.en.json (100%) rename {src => app/src}/langs/control/ReportControl.de.json (100%) rename {src => app/src}/langs/control/ReportControl.en.json (100%) rename {src => app/src}/langs/control/UpdateControl.de.json (100%) rename {src => app/src}/langs/control/UpdateControl.en.json (100%) rename {src => app/src}/langs/other/EpisodeLanguage.en.json (100%) rename {src => app/src}/langs/other/Genres.de.json (100%) rename {src => app/src}/langs/other/Genres.en.json (100%) rename {src => app/src}/langs/views/ListView.de.json (100%) rename {src => app/src}/langs/views/ListView.en.json (100%) rename {src => app/src}/langs/views/PlayerView.de.json (100%) rename {src => app/src}/langs/views/PlayerView.en.json (100%) rename {src => app/src}/langs/views/ProviderView.de.json (100%) rename {src => app/src}/langs/views/ProviderView.en.json (100%) rename {src => app/src}/langs/views/SeriesSyncView.de.json (100%) rename {src => app/src}/langs/views/SeriesSyncView.en.json (100%) rename {src => app/src}/langs/views/SettingsView.de.json (100%) rename {src => app/src}/langs/views/SettingsView.en.json (100%) rename {src => app/src}/langs/views/StreamView.de.json (100%) rename {src => app/src}/langs/views/StreamView.en.json (100%) rename {src => app/src}/langs/views/StreamsView.de.json (100%) rename {src => app/src}/langs/views/StreamsView.en.json (100%) rename {src => app/src}/langs/views/SyncView.de.json (100%) rename {src => app/src}/langs/views/SyncView.en.json (100%) rename {src => app/src}/langs/views/WatchlistView.de.json (100%) rename {src => app/src}/langs/views/WatchlistView.en.json (100%) rename {src => app/src}/main.css (100%) rename {src => app/src}/main.ts (100%) rename {src => app/src}/models/episode.model.ts (100%) rename {src => app/src}/models/genre.model.ts (100%) rename {src => app/src}/models/list.model.ts (100%) rename {src => app/src}/models/profile.model.ts (100%) rename {src => app/src}/models/season.model.ts (100%) rename {src => app/src}/models/series.model.ts (100%) rename {src => app/src}/models/watchtime.model.ts (100%) rename {src => app/src}/providers/aniworld/fetcher.ts (100%) rename {src => app/src}/providers/aniworld/index.ts (100%) rename {src => app/src}/providers/aniworld/provider.ts (100%) rename {src => app/src}/providers/default.ts (100%) rename {src => app/src}/providers/sto/fetcher.ts (100%) rename {src => app/src}/providers/sto/index.ts (100%) rename {src => app/src}/providers/sto/provider.ts (100%) rename {src => app/src}/services/shared/changelog.service.ts (100%) rename {src => app/src}/services/shared/declaration.ts (100%) rename {src => app/src}/services/shared/i18n.service.ts (100%) rename {src => app/src}/services/shared/report.service.ts (100%) rename {src => app/src}/services/standalone/db/metadata.service.ts (100%) rename {src => app/src}/services/standalone/db/user.service.ts (100%) rename {src => app/src}/services/standalone/episode.service.ts (100%) rename {src => app/src}/services/standalone/fetch.service.ts (100%) rename {src => app/src}/services/standalone/genre.service.ts (100%) rename {src => app/src}/services/standalone/list.service.ts (100%) rename {src => app/src}/services/standalone/provider.service.ts (100%) rename {src => app/src}/services/standalone/season.service.ts (100%) rename {src => app/src}/services/standalone/series.service.ts (100%) rename {src => app/src}/services/standalone/settings.service.ts (100%) rename {src => app/src}/services/standalone/update.service.ts (100%) rename {src => app/src}/services/standalone/user.service.ts (100%) rename {src => app/src}/services/standalone/utils/db.ts (100%) rename {src => app/src}/services/standalone/watchlist.service.ts (100%) rename {src => app/src}/services/standalone/watchtime.service.ts (100%) rename {src => app/src}/sources/doodstream.ts (100%) rename {src => app/src}/sources/filemoon.ts (100%) rename {src => app/src}/sources/index.ts (100%) rename {src => app/src}/sources/loadx.ts (100%) rename {src => app/src}/sources/luluvdo.ts (100%) rename {src => app/src}/sources/speedfiles.ts (100%) rename {src => app/src}/sources/vidmoly.ts (100%) rename {src => app/src}/sources/vidoza.ts (100%) rename {src => app/src}/sources/voe.ts (100%) rename {src => app/src}/utils/array.ts (100%) rename {src => app/src}/utils/hash.ts (100%) rename {src => app/src}/utils/hls-tauri-bridge.ts (100%) rename {src => app/src}/utils/http.ts (100%) rename {src => app/src}/utils/markdown.ts (100%) rename {src => app/src}/utils/string.ts (100%) rename {src => app/src}/utils/throttle.ts (100%) rename {src => app/src}/views/ListView.model.ts (100%) rename {src => app/src}/views/ListView.vue (100%) rename {src => app/src}/views/PlayerView.model.ts (100%) rename {src => app/src}/views/PlayerView.vue (100%) rename {src => app/src}/views/ProfileView.model.ts (100%) rename {src => app/src}/views/ProfileView.vue (100%) rename {src => app/src}/views/ProviderView.model.ts (100%) rename {src => app/src}/views/ProviderView.vue (100%) rename {src => app/src}/views/SeriesSyncView.model.ts (100%) rename {src => app/src}/views/SeriesSyncView.vue (100%) rename {src => app/src}/views/SettingsView.model.ts (100%) rename {src => app/src}/views/SettingsView.vue (100%) rename {src => app/src}/views/StreamView.model.ts (100%) rename {src => app/src}/views/StreamView.vue (100%) rename {src => app/src}/views/StreamsView.model.ts (100%) rename {src => app/src}/views/StreamsView.vue (100%) rename {src => app/src}/views/SyncView.model.ts (100%) rename {src => app/src}/views/SyncView.vue (100%) rename {src => app/src}/views/WatchlistView.model.ts (100%) rename {src => app/src}/views/WatchlistView.vue (100%) rename {src => app/src}/vite-env.d.ts (100%) rename tsconfig.json => app/tsconfig.json (100%) rename tsconfig.node.json => app/tsconfig.node.json (100%) rename vite.config.ts => app/vite.config.ts (100%) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index ff76e4e..9bf8f2f 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -21,7 +21,7 @@ jobs: - name: Ensure changelog exists run: | - CHANGELOG="changelogs/v${{ github.event.inputs.version }}.md" + CHANGELOG="app/changelogs/v${{ github.event.inputs.version }}.md" if [ ! -f "$CHANGELOG" ]; then echo "Missing changelog: $CHANGELOG" exit 1 @@ -55,16 +55,16 @@ jobs: echo "Setting version to ${{ github.event.inputs.version }}" # package.json - jq ".version = \"${{ github.event.inputs.version }}\"" package.json > package.json.tmp - mv package.json.tmp package.json + jq ".version = \"${{ github.event.inputs.version }}\"" app/package.json > app/package.json.tmp + mv app/package.json.tmp app/package.json # tauri.conf.json (Tauri v2) - jq ".version = \"${{ github.event.inputs.version }}\"" src-tauri/tauri.conf.json > src-tauri/tauri.conf.json.tmp - mv src-tauri/tauri.conf.json.tmp src-tauri/tauri.conf.json + jq ".version = \"${{ github.event.inputs.version }}\"" app/src-tauri/tauri.conf.json > app/src-tauri/tauri.conf.json.tmp + mv app/src-tauri/tauri.conf.json.tmp app/src-tauri/tauri.conf.json # Cargo.toml (optional but strongly recommended) - sed -i.bak "s/^version = \".*\"/version = \"${{ github.event.inputs.version }}\"/" src-tauri/Cargo.toml || true - rm -f src-tauri/Cargo.toml.bak + sed -i.bak "s/^version = \".*\"/version = \"${{ github.event.inputs.version }}\"/" app/src-tauri/Cargo.toml || true + rm -f app/src-tauri/Cargo.toml.bak echo "✅ Versions updated" @@ -109,7 +109,7 @@ jobs: id: changelog shell: bash run: | - CHANGELOG=$(cat changelogs/v${{ github.event.inputs.version }}.md) + CHANGELOG=$(cat app/changelogs/v${{ github.event.inputs.version }}.md) { echo "content< diff --git a/src/controls/InfoToast.model.ts b/app/src/controls/InfoToast.model.ts similarity index 100% rename from src/controls/InfoToast.model.ts rename to app/src/controls/InfoToast.model.ts diff --git a/src/controls/InfoToast.vue b/app/src/controls/InfoToast.vue similarity index 100% rename from src/controls/InfoToast.vue rename to app/src/controls/InfoToast.vue diff --git a/src/controls/ListHash.vue b/app/src/controls/ListHash.vue similarity index 100% rename from src/controls/ListHash.vue rename to app/src/controls/ListHash.vue diff --git a/src/controls/PrefControl.model.ts b/app/src/controls/PrefControl.model.ts similarity index 100% rename from src/controls/PrefControl.model.ts rename to app/src/controls/PrefControl.model.ts diff --git a/src/controls/PrefControl.vue b/app/src/controls/PrefControl.vue similarity index 100% rename from src/controls/PrefControl.vue rename to app/src/controls/PrefControl.vue diff --git a/src/controls/ProfileDialog.model.ts b/app/src/controls/ProfileDialog.model.ts similarity index 100% rename from src/controls/ProfileDialog.model.ts rename to app/src/controls/ProfileDialog.model.ts diff --git a/src/controls/ProfileDialog.vue b/app/src/controls/ProfileDialog.vue similarity index 100% rename from src/controls/ProfileDialog.vue rename to app/src/controls/ProfileDialog.vue diff --git a/src/controls/ProfileSetupControl.model.ts b/app/src/controls/ProfileSetupControl.model.ts similarity index 100% rename from src/controls/ProfileSetupControl.model.ts rename to app/src/controls/ProfileSetupControl.model.ts diff --git a/src/controls/ProfileSetupControl.vue b/app/src/controls/ProfileSetupControl.vue similarity index 100% rename from src/controls/ProfileSetupControl.vue rename to app/src/controls/ProfileSetupControl.vue diff --git a/src/controls/ProgressToast.model.ts b/app/src/controls/ProgressToast.model.ts similarity index 100% rename from src/controls/ProgressToast.model.ts rename to app/src/controls/ProgressToast.model.ts diff --git a/src/controls/ProgressToast.vue b/app/src/controls/ProgressToast.vue similarity index 100% rename from src/controls/ProgressToast.vue rename to app/src/controls/ProgressToast.vue diff --git a/src/controls/ReportControl.model.ts b/app/src/controls/ReportControl.model.ts similarity index 100% rename from src/controls/ReportControl.model.ts rename to app/src/controls/ReportControl.model.ts diff --git a/src/controls/ReportControl.vue b/app/src/controls/ReportControl.vue similarity index 100% rename from src/controls/ReportControl.vue rename to app/src/controls/ReportControl.vue diff --git a/src/controls/Text.model.ts b/app/src/controls/Text.model.ts similarity index 100% rename from src/controls/Text.model.ts rename to app/src/controls/Text.model.ts diff --git a/src/controls/Text.vue b/app/src/controls/Text.vue similarity index 100% rename from src/controls/Text.vue rename to app/src/controls/Text.vue diff --git a/src/controls/ToSControl.model.ts b/app/src/controls/ToSControl.model.ts similarity index 100% rename from src/controls/ToSControl.model.ts rename to app/src/controls/ToSControl.model.ts diff --git a/src/controls/ToSControl.vue b/app/src/controls/ToSControl.vue similarity index 95% rename from src/controls/ToSControl.vue rename to app/src/controls/ToSControl.vue index d00d3ac..8d2a54b 100644 --- a/src/controls/ToSControl.vue +++ b/app/src/controls/ToSControl.vue @@ -3,8 +3,8 @@ import {useDialogControl} from "vue-mvvm/dialog"; import {ToSControlModel} from "@controls/ToSControl.model"; import LucideInfo from "@icons/LucideInfo.vue"; -import tos from "@/../ToS.txt?raw"; -import disclaimer from "@/../LegalDisclaimer.txt?raw"; +import tos from "@/../../ToS.txt?raw"; +import disclaimer from "@/../../LegalDisclaimer.txt?raw"; const vm: ToSControlModel = useDialogControl(ToSControlModel); diff --git a/src/controls/ToastContainer.vue b/app/src/controls/ToastContainer.vue similarity index 100% rename from src/controls/ToastContainer.vue rename to app/src/controls/ToastContainer.vue diff --git a/src/controls/UpdateControl.model.ts b/app/src/controls/UpdateControl.model.ts similarity index 100% rename from src/controls/UpdateControl.model.ts rename to app/src/controls/UpdateControl.model.ts diff --git a/src/controls/UpdateControl.vue b/app/src/controls/UpdateControl.vue similarity index 100% rename from src/controls/UpdateControl.vue rename to app/src/controls/UpdateControl.vue diff --git a/src/icons/FlowbiteGithubSolid.vue b/app/src/icons/FlowbiteGithubSolid.vue similarity index 100% rename from src/icons/FlowbiteGithubSolid.vue rename to app/src/icons/FlowbiteGithubSolid.vue diff --git a/src/icons/LucideArrowLeft.vue b/app/src/icons/LucideArrowLeft.vue similarity index 100% rename from src/icons/LucideArrowLeft.vue rename to app/src/icons/LucideArrowLeft.vue diff --git a/src/icons/LucideArrowRight.vue b/app/src/icons/LucideArrowRight.vue similarity index 100% rename from src/icons/LucideArrowRight.vue rename to app/src/icons/LucideArrowRight.vue diff --git a/src/icons/LucideCheck.vue b/app/src/icons/LucideCheck.vue similarity index 100% rename from src/icons/LucideCheck.vue rename to app/src/icons/LucideCheck.vue diff --git a/src/icons/LucideCloudSync.vue b/app/src/icons/LucideCloudSync.vue similarity index 100% rename from src/icons/LucideCloudSync.vue rename to app/src/icons/LucideCloudSync.vue diff --git a/src/icons/LucideEdit.vue b/app/src/icons/LucideEdit.vue similarity index 100% rename from src/icons/LucideEdit.vue rename to app/src/icons/LucideEdit.vue diff --git a/src/icons/LucideEllipsisVertical.vue b/app/src/icons/LucideEllipsisVertical.vue similarity index 100% rename from src/icons/LucideEllipsisVertical.vue rename to app/src/icons/LucideEllipsisVertical.vue diff --git a/src/icons/LucideEye.vue b/app/src/icons/LucideEye.vue similarity index 100% rename from src/icons/LucideEye.vue rename to app/src/icons/LucideEye.vue diff --git a/src/icons/LucideEyeOff.vue b/app/src/icons/LucideEyeOff.vue similarity index 100% rename from src/icons/LucideEyeOff.vue rename to app/src/icons/LucideEyeOff.vue diff --git a/src/icons/LucideFolder.vue b/app/src/icons/LucideFolder.vue similarity index 100% rename from src/icons/LucideFolder.vue rename to app/src/icons/LucideFolder.vue diff --git a/src/icons/LucideHeart.vue b/app/src/icons/LucideHeart.vue similarity index 100% rename from src/icons/LucideHeart.vue rename to app/src/icons/LucideHeart.vue diff --git a/src/icons/LucideHistory.vue b/app/src/icons/LucideHistory.vue similarity index 100% rename from src/icons/LucideHistory.vue rename to app/src/icons/LucideHistory.vue diff --git a/src/icons/LucideInfo.vue b/app/src/icons/LucideInfo.vue similarity index 100% rename from src/icons/LucideInfo.vue rename to app/src/icons/LucideInfo.vue diff --git a/src/icons/LucideListVideo.vue b/app/src/icons/LucideListVideo.vue similarity index 100% rename from src/icons/LucideListVideo.vue rename to app/src/icons/LucideListVideo.vue diff --git a/src/icons/LucideMaximize.vue b/app/src/icons/LucideMaximize.vue similarity index 100% rename from src/icons/LucideMaximize.vue rename to app/src/icons/LucideMaximize.vue diff --git a/src/icons/LucideMinimize.vue b/app/src/icons/LucideMinimize.vue similarity index 100% rename from src/icons/LucideMinimize.vue rename to app/src/icons/LucideMinimize.vue diff --git a/src/icons/LucideNewspaper.vue b/app/src/icons/LucideNewspaper.vue similarity index 100% rename from src/icons/LucideNewspaper.vue rename to app/src/icons/LucideNewspaper.vue diff --git a/src/icons/LucidePalette.vue b/app/src/icons/LucidePalette.vue similarity index 100% rename from src/icons/LucidePalette.vue rename to app/src/icons/LucidePalette.vue diff --git a/src/icons/LucidePause.vue b/app/src/icons/LucidePause.vue similarity index 100% rename from src/icons/LucidePause.vue rename to app/src/icons/LucidePause.vue diff --git a/src/icons/LucidePlay.vue b/app/src/icons/LucidePlay.vue similarity index 100% rename from src/icons/LucidePlay.vue rename to app/src/icons/LucidePlay.vue diff --git a/src/icons/LucidePlus.vue b/app/src/icons/LucidePlus.vue similarity index 100% rename from src/icons/LucidePlus.vue rename to app/src/icons/LucidePlus.vue diff --git a/src/icons/LucidePopcorn.vue b/app/src/icons/LucidePopcorn.vue similarity index 100% rename from src/icons/LucidePopcorn.vue rename to app/src/icons/LucidePopcorn.vue diff --git a/src/icons/LucideSearch.vue b/app/src/icons/LucideSearch.vue similarity index 100% rename from src/icons/LucideSearch.vue rename to app/src/icons/LucideSearch.vue diff --git a/src/icons/LucideSettings.vue b/app/src/icons/LucideSettings.vue similarity index 100% rename from src/icons/LucideSettings.vue rename to app/src/icons/LucideSettings.vue diff --git a/src/icons/LucideTimerReset.vue b/app/src/icons/LucideTimerReset.vue similarity index 100% rename from src/icons/LucideTimerReset.vue rename to app/src/icons/LucideTimerReset.vue diff --git a/src/icons/LucideTrash.vue b/app/src/icons/LucideTrash.vue similarity index 100% rename from src/icons/LucideTrash.vue rename to app/src/icons/LucideTrash.vue diff --git a/src/icons/LucideX.vue b/app/src/icons/LucideX.vue similarity index 100% rename from src/icons/LucideX.vue rename to app/src/icons/LucideX.vue diff --git a/src/icons/SvgSpinnersBlocksWave.vue b/app/src/icons/SvgSpinnersBlocksWave.vue similarity index 100% rename from src/icons/SvgSpinnersBlocksWave.vue rename to app/src/icons/SvgSpinnersBlocksWave.vue diff --git a/src/langs/control/DetailControl.de.json b/app/src/langs/control/DetailControl.de.json similarity index 100% rename from src/langs/control/DetailControl.de.json rename to app/src/langs/control/DetailControl.de.json diff --git a/src/langs/control/DetailControl.en.json b/app/src/langs/control/DetailControl.en.json similarity index 100% rename from src/langs/control/DetailControl.en.json rename to app/src/langs/control/DetailControl.en.json diff --git a/src/langs/control/HLSPlayer.de.json b/app/src/langs/control/HLSPlayer.de.json similarity index 100% rename from src/langs/control/HLSPlayer.de.json rename to app/src/langs/control/HLSPlayer.de.json diff --git a/src/langs/control/HLSPlayer.en.json b/app/src/langs/control/HLSPlayer.en.json similarity index 100% rename from src/langs/control/HLSPlayer.en.json rename to app/src/langs/control/HLSPlayer.en.json diff --git a/src/langs/control/InfoControl.de.json b/app/src/langs/control/InfoControl.de.json similarity index 100% rename from src/langs/control/InfoControl.de.json rename to app/src/langs/control/InfoControl.de.json diff --git a/src/langs/control/InfoControl.en.json b/app/src/langs/control/InfoControl.en.json similarity index 100% rename from src/langs/control/InfoControl.en.json rename to app/src/langs/control/InfoControl.en.json diff --git a/src/langs/control/PrefControl.de.json b/app/src/langs/control/PrefControl.de.json similarity index 100% rename from src/langs/control/PrefControl.de.json rename to app/src/langs/control/PrefControl.de.json diff --git a/src/langs/control/PrefControl.en.json b/app/src/langs/control/PrefControl.en.json similarity index 100% rename from src/langs/control/PrefControl.en.json rename to app/src/langs/control/PrefControl.en.json diff --git a/src/langs/control/ProfileSetupControl.de.json b/app/src/langs/control/ProfileSetupControl.de.json similarity index 100% rename from src/langs/control/ProfileSetupControl.de.json rename to app/src/langs/control/ProfileSetupControl.de.json diff --git a/src/langs/control/ProfileSetupControl.en.json b/app/src/langs/control/ProfileSetupControl.en.json similarity index 100% rename from src/langs/control/ProfileSetupControl.en.json rename to app/src/langs/control/ProfileSetupControl.en.json diff --git a/src/langs/control/ReportControl.de.json b/app/src/langs/control/ReportControl.de.json similarity index 100% rename from src/langs/control/ReportControl.de.json rename to app/src/langs/control/ReportControl.de.json diff --git a/src/langs/control/ReportControl.en.json b/app/src/langs/control/ReportControl.en.json similarity index 100% rename from src/langs/control/ReportControl.en.json rename to app/src/langs/control/ReportControl.en.json diff --git a/src/langs/control/UpdateControl.de.json b/app/src/langs/control/UpdateControl.de.json similarity index 100% rename from src/langs/control/UpdateControl.de.json rename to app/src/langs/control/UpdateControl.de.json diff --git a/src/langs/control/UpdateControl.en.json b/app/src/langs/control/UpdateControl.en.json similarity index 100% rename from src/langs/control/UpdateControl.en.json rename to app/src/langs/control/UpdateControl.en.json diff --git a/src/langs/other/EpisodeLanguage.en.json b/app/src/langs/other/EpisodeLanguage.en.json similarity index 100% rename from src/langs/other/EpisodeLanguage.en.json rename to app/src/langs/other/EpisodeLanguage.en.json diff --git a/src/langs/other/Genres.de.json b/app/src/langs/other/Genres.de.json similarity index 100% rename from src/langs/other/Genres.de.json rename to app/src/langs/other/Genres.de.json diff --git a/src/langs/other/Genres.en.json b/app/src/langs/other/Genres.en.json similarity index 100% rename from src/langs/other/Genres.en.json rename to app/src/langs/other/Genres.en.json diff --git a/src/langs/views/ListView.de.json b/app/src/langs/views/ListView.de.json similarity index 100% rename from src/langs/views/ListView.de.json rename to app/src/langs/views/ListView.de.json diff --git a/src/langs/views/ListView.en.json b/app/src/langs/views/ListView.en.json similarity index 100% rename from src/langs/views/ListView.en.json rename to app/src/langs/views/ListView.en.json diff --git a/src/langs/views/PlayerView.de.json b/app/src/langs/views/PlayerView.de.json similarity index 100% rename from src/langs/views/PlayerView.de.json rename to app/src/langs/views/PlayerView.de.json diff --git a/src/langs/views/PlayerView.en.json b/app/src/langs/views/PlayerView.en.json similarity index 100% rename from src/langs/views/PlayerView.en.json rename to app/src/langs/views/PlayerView.en.json diff --git a/src/langs/views/ProviderView.de.json b/app/src/langs/views/ProviderView.de.json similarity index 100% rename from src/langs/views/ProviderView.de.json rename to app/src/langs/views/ProviderView.de.json diff --git a/src/langs/views/ProviderView.en.json b/app/src/langs/views/ProviderView.en.json similarity index 100% rename from src/langs/views/ProviderView.en.json rename to app/src/langs/views/ProviderView.en.json diff --git a/src/langs/views/SeriesSyncView.de.json b/app/src/langs/views/SeriesSyncView.de.json similarity index 100% rename from src/langs/views/SeriesSyncView.de.json rename to app/src/langs/views/SeriesSyncView.de.json diff --git a/src/langs/views/SeriesSyncView.en.json b/app/src/langs/views/SeriesSyncView.en.json similarity index 100% rename from src/langs/views/SeriesSyncView.en.json rename to app/src/langs/views/SeriesSyncView.en.json diff --git a/src/langs/views/SettingsView.de.json b/app/src/langs/views/SettingsView.de.json similarity index 100% rename from src/langs/views/SettingsView.de.json rename to app/src/langs/views/SettingsView.de.json diff --git a/src/langs/views/SettingsView.en.json b/app/src/langs/views/SettingsView.en.json similarity index 100% rename from src/langs/views/SettingsView.en.json rename to app/src/langs/views/SettingsView.en.json diff --git a/src/langs/views/StreamView.de.json b/app/src/langs/views/StreamView.de.json similarity index 100% rename from src/langs/views/StreamView.de.json rename to app/src/langs/views/StreamView.de.json diff --git a/src/langs/views/StreamView.en.json b/app/src/langs/views/StreamView.en.json similarity index 100% rename from src/langs/views/StreamView.en.json rename to app/src/langs/views/StreamView.en.json diff --git a/src/langs/views/StreamsView.de.json b/app/src/langs/views/StreamsView.de.json similarity index 100% rename from src/langs/views/StreamsView.de.json rename to app/src/langs/views/StreamsView.de.json diff --git a/src/langs/views/StreamsView.en.json b/app/src/langs/views/StreamsView.en.json similarity index 100% rename from src/langs/views/StreamsView.en.json rename to app/src/langs/views/StreamsView.en.json diff --git a/src/langs/views/SyncView.de.json b/app/src/langs/views/SyncView.de.json similarity index 100% rename from src/langs/views/SyncView.de.json rename to app/src/langs/views/SyncView.de.json diff --git a/src/langs/views/SyncView.en.json b/app/src/langs/views/SyncView.en.json similarity index 100% rename from src/langs/views/SyncView.en.json rename to app/src/langs/views/SyncView.en.json diff --git a/src/langs/views/WatchlistView.de.json b/app/src/langs/views/WatchlistView.de.json similarity index 100% rename from src/langs/views/WatchlistView.de.json rename to app/src/langs/views/WatchlistView.de.json diff --git a/src/langs/views/WatchlistView.en.json b/app/src/langs/views/WatchlistView.en.json similarity index 100% rename from src/langs/views/WatchlistView.en.json rename to app/src/langs/views/WatchlistView.en.json diff --git a/src/main.css b/app/src/main.css similarity index 100% rename from src/main.css rename to app/src/main.css diff --git a/src/main.ts b/app/src/main.ts similarity index 100% rename from src/main.ts rename to app/src/main.ts diff --git a/src/models/episode.model.ts b/app/src/models/episode.model.ts similarity index 100% rename from src/models/episode.model.ts rename to app/src/models/episode.model.ts diff --git a/src/models/genre.model.ts b/app/src/models/genre.model.ts similarity index 100% rename from src/models/genre.model.ts rename to app/src/models/genre.model.ts diff --git a/src/models/list.model.ts b/app/src/models/list.model.ts similarity index 100% rename from src/models/list.model.ts rename to app/src/models/list.model.ts diff --git a/src/models/profile.model.ts b/app/src/models/profile.model.ts similarity index 100% rename from src/models/profile.model.ts rename to app/src/models/profile.model.ts diff --git a/src/models/season.model.ts b/app/src/models/season.model.ts similarity index 100% rename from src/models/season.model.ts rename to app/src/models/season.model.ts diff --git a/src/models/series.model.ts b/app/src/models/series.model.ts similarity index 100% rename from src/models/series.model.ts rename to app/src/models/series.model.ts diff --git a/src/models/watchtime.model.ts b/app/src/models/watchtime.model.ts similarity index 100% rename from src/models/watchtime.model.ts rename to app/src/models/watchtime.model.ts diff --git a/src/providers/aniworld/fetcher.ts b/app/src/providers/aniworld/fetcher.ts similarity index 100% rename from src/providers/aniworld/fetcher.ts rename to app/src/providers/aniworld/fetcher.ts diff --git a/src/providers/aniworld/index.ts b/app/src/providers/aniworld/index.ts similarity index 100% rename from src/providers/aniworld/index.ts rename to app/src/providers/aniworld/index.ts diff --git a/src/providers/aniworld/provider.ts b/app/src/providers/aniworld/provider.ts similarity index 100% rename from src/providers/aniworld/provider.ts rename to app/src/providers/aniworld/provider.ts diff --git a/src/providers/default.ts b/app/src/providers/default.ts similarity index 100% rename from src/providers/default.ts rename to app/src/providers/default.ts diff --git a/src/providers/sto/fetcher.ts b/app/src/providers/sto/fetcher.ts similarity index 100% rename from src/providers/sto/fetcher.ts rename to app/src/providers/sto/fetcher.ts diff --git a/src/providers/sto/index.ts b/app/src/providers/sto/index.ts similarity index 100% rename from src/providers/sto/index.ts rename to app/src/providers/sto/index.ts diff --git a/src/providers/sto/provider.ts b/app/src/providers/sto/provider.ts similarity index 100% rename from src/providers/sto/provider.ts rename to app/src/providers/sto/provider.ts diff --git a/src/services/shared/changelog.service.ts b/app/src/services/shared/changelog.service.ts similarity index 100% rename from src/services/shared/changelog.service.ts rename to app/src/services/shared/changelog.service.ts diff --git a/src/services/shared/declaration.ts b/app/src/services/shared/declaration.ts similarity index 100% rename from src/services/shared/declaration.ts rename to app/src/services/shared/declaration.ts diff --git a/src/services/shared/i18n.service.ts b/app/src/services/shared/i18n.service.ts similarity index 100% rename from src/services/shared/i18n.service.ts rename to app/src/services/shared/i18n.service.ts diff --git a/src/services/shared/report.service.ts b/app/src/services/shared/report.service.ts similarity index 100% rename from src/services/shared/report.service.ts rename to app/src/services/shared/report.service.ts diff --git a/src/services/standalone/db/metadata.service.ts b/app/src/services/standalone/db/metadata.service.ts similarity index 100% rename from src/services/standalone/db/metadata.service.ts rename to app/src/services/standalone/db/metadata.service.ts diff --git a/src/services/standalone/db/user.service.ts b/app/src/services/standalone/db/user.service.ts similarity index 100% rename from src/services/standalone/db/user.service.ts rename to app/src/services/standalone/db/user.service.ts diff --git a/src/services/standalone/episode.service.ts b/app/src/services/standalone/episode.service.ts similarity index 100% rename from src/services/standalone/episode.service.ts rename to app/src/services/standalone/episode.service.ts diff --git a/src/services/standalone/fetch.service.ts b/app/src/services/standalone/fetch.service.ts similarity index 100% rename from src/services/standalone/fetch.service.ts rename to app/src/services/standalone/fetch.service.ts diff --git a/src/services/standalone/genre.service.ts b/app/src/services/standalone/genre.service.ts similarity index 100% rename from src/services/standalone/genre.service.ts rename to app/src/services/standalone/genre.service.ts diff --git a/src/services/standalone/list.service.ts b/app/src/services/standalone/list.service.ts similarity index 100% rename from src/services/standalone/list.service.ts rename to app/src/services/standalone/list.service.ts diff --git a/src/services/standalone/provider.service.ts b/app/src/services/standalone/provider.service.ts similarity index 100% rename from src/services/standalone/provider.service.ts rename to app/src/services/standalone/provider.service.ts diff --git a/src/services/standalone/season.service.ts b/app/src/services/standalone/season.service.ts similarity index 100% rename from src/services/standalone/season.service.ts rename to app/src/services/standalone/season.service.ts diff --git a/src/services/standalone/series.service.ts b/app/src/services/standalone/series.service.ts similarity index 100% rename from src/services/standalone/series.service.ts rename to app/src/services/standalone/series.service.ts diff --git a/src/services/standalone/settings.service.ts b/app/src/services/standalone/settings.service.ts similarity index 100% rename from src/services/standalone/settings.service.ts rename to app/src/services/standalone/settings.service.ts diff --git a/src/services/standalone/update.service.ts b/app/src/services/standalone/update.service.ts similarity index 100% rename from src/services/standalone/update.service.ts rename to app/src/services/standalone/update.service.ts diff --git a/src/services/standalone/user.service.ts b/app/src/services/standalone/user.service.ts similarity index 100% rename from src/services/standalone/user.service.ts rename to app/src/services/standalone/user.service.ts diff --git a/src/services/standalone/utils/db.ts b/app/src/services/standalone/utils/db.ts similarity index 100% rename from src/services/standalone/utils/db.ts rename to app/src/services/standalone/utils/db.ts diff --git a/src/services/standalone/watchlist.service.ts b/app/src/services/standalone/watchlist.service.ts similarity index 100% rename from src/services/standalone/watchlist.service.ts rename to app/src/services/standalone/watchlist.service.ts diff --git a/src/services/standalone/watchtime.service.ts b/app/src/services/standalone/watchtime.service.ts similarity index 100% rename from src/services/standalone/watchtime.service.ts rename to app/src/services/standalone/watchtime.service.ts diff --git a/src/sources/doodstream.ts b/app/src/sources/doodstream.ts similarity index 100% rename from src/sources/doodstream.ts rename to app/src/sources/doodstream.ts diff --git a/src/sources/filemoon.ts b/app/src/sources/filemoon.ts similarity index 100% rename from src/sources/filemoon.ts rename to app/src/sources/filemoon.ts diff --git a/src/sources/index.ts b/app/src/sources/index.ts similarity index 100% rename from src/sources/index.ts rename to app/src/sources/index.ts diff --git a/src/sources/loadx.ts b/app/src/sources/loadx.ts similarity index 100% rename from src/sources/loadx.ts rename to app/src/sources/loadx.ts diff --git a/src/sources/luluvdo.ts b/app/src/sources/luluvdo.ts similarity index 100% rename from src/sources/luluvdo.ts rename to app/src/sources/luluvdo.ts diff --git a/src/sources/speedfiles.ts b/app/src/sources/speedfiles.ts similarity index 100% rename from src/sources/speedfiles.ts rename to app/src/sources/speedfiles.ts diff --git a/src/sources/vidmoly.ts b/app/src/sources/vidmoly.ts similarity index 100% rename from src/sources/vidmoly.ts rename to app/src/sources/vidmoly.ts diff --git a/src/sources/vidoza.ts b/app/src/sources/vidoza.ts similarity index 100% rename from src/sources/vidoza.ts rename to app/src/sources/vidoza.ts diff --git a/src/sources/voe.ts b/app/src/sources/voe.ts similarity index 100% rename from src/sources/voe.ts rename to app/src/sources/voe.ts diff --git a/src/utils/array.ts b/app/src/utils/array.ts similarity index 100% rename from src/utils/array.ts rename to app/src/utils/array.ts diff --git a/src/utils/hash.ts b/app/src/utils/hash.ts similarity index 100% rename from src/utils/hash.ts rename to app/src/utils/hash.ts diff --git a/src/utils/hls-tauri-bridge.ts b/app/src/utils/hls-tauri-bridge.ts similarity index 100% rename from src/utils/hls-tauri-bridge.ts rename to app/src/utils/hls-tauri-bridge.ts diff --git a/src/utils/http.ts b/app/src/utils/http.ts similarity index 100% rename from src/utils/http.ts rename to app/src/utils/http.ts diff --git a/src/utils/markdown.ts b/app/src/utils/markdown.ts similarity index 100% rename from src/utils/markdown.ts rename to app/src/utils/markdown.ts diff --git a/src/utils/string.ts b/app/src/utils/string.ts similarity index 100% rename from src/utils/string.ts rename to app/src/utils/string.ts diff --git a/src/utils/throttle.ts b/app/src/utils/throttle.ts similarity index 100% rename from src/utils/throttle.ts rename to app/src/utils/throttle.ts diff --git a/src/views/ListView.model.ts b/app/src/views/ListView.model.ts similarity index 100% rename from src/views/ListView.model.ts rename to app/src/views/ListView.model.ts diff --git a/src/views/ListView.vue b/app/src/views/ListView.vue similarity index 100% rename from src/views/ListView.vue rename to app/src/views/ListView.vue diff --git a/src/views/PlayerView.model.ts b/app/src/views/PlayerView.model.ts similarity index 100% rename from src/views/PlayerView.model.ts rename to app/src/views/PlayerView.model.ts diff --git a/src/views/PlayerView.vue b/app/src/views/PlayerView.vue similarity index 100% rename from src/views/PlayerView.vue rename to app/src/views/PlayerView.vue diff --git a/src/views/ProfileView.model.ts b/app/src/views/ProfileView.model.ts similarity index 100% rename from src/views/ProfileView.model.ts rename to app/src/views/ProfileView.model.ts diff --git a/src/views/ProfileView.vue b/app/src/views/ProfileView.vue similarity index 100% rename from src/views/ProfileView.vue rename to app/src/views/ProfileView.vue diff --git a/src/views/ProviderView.model.ts b/app/src/views/ProviderView.model.ts similarity index 100% rename from src/views/ProviderView.model.ts rename to app/src/views/ProviderView.model.ts diff --git a/src/views/ProviderView.vue b/app/src/views/ProviderView.vue similarity index 100% rename from src/views/ProviderView.vue rename to app/src/views/ProviderView.vue diff --git a/src/views/SeriesSyncView.model.ts b/app/src/views/SeriesSyncView.model.ts similarity index 100% rename from src/views/SeriesSyncView.model.ts rename to app/src/views/SeriesSyncView.model.ts diff --git a/src/views/SeriesSyncView.vue b/app/src/views/SeriesSyncView.vue similarity index 100% rename from src/views/SeriesSyncView.vue rename to app/src/views/SeriesSyncView.vue diff --git a/src/views/SettingsView.model.ts b/app/src/views/SettingsView.model.ts similarity index 100% rename from src/views/SettingsView.model.ts rename to app/src/views/SettingsView.model.ts diff --git a/src/views/SettingsView.vue b/app/src/views/SettingsView.vue similarity index 100% rename from src/views/SettingsView.vue rename to app/src/views/SettingsView.vue diff --git a/src/views/StreamView.model.ts b/app/src/views/StreamView.model.ts similarity index 100% rename from src/views/StreamView.model.ts rename to app/src/views/StreamView.model.ts diff --git a/src/views/StreamView.vue b/app/src/views/StreamView.vue similarity index 100% rename from src/views/StreamView.vue rename to app/src/views/StreamView.vue diff --git a/src/views/StreamsView.model.ts b/app/src/views/StreamsView.model.ts similarity index 100% rename from src/views/StreamsView.model.ts rename to app/src/views/StreamsView.model.ts diff --git a/src/views/StreamsView.vue b/app/src/views/StreamsView.vue similarity index 100% rename from src/views/StreamsView.vue rename to app/src/views/StreamsView.vue diff --git a/src/views/SyncView.model.ts b/app/src/views/SyncView.model.ts similarity index 100% rename from src/views/SyncView.model.ts rename to app/src/views/SyncView.model.ts diff --git a/src/views/SyncView.vue b/app/src/views/SyncView.vue similarity index 100% rename from src/views/SyncView.vue rename to app/src/views/SyncView.vue diff --git a/src/views/WatchlistView.model.ts b/app/src/views/WatchlistView.model.ts similarity index 100% rename from src/views/WatchlistView.model.ts rename to app/src/views/WatchlistView.model.ts diff --git a/src/views/WatchlistView.vue b/app/src/views/WatchlistView.vue similarity index 100% rename from src/views/WatchlistView.vue rename to app/src/views/WatchlistView.vue diff --git a/src/vite-env.d.ts b/app/src/vite-env.d.ts similarity index 100% rename from src/vite-env.d.ts rename to app/src/vite-env.d.ts diff --git a/tsconfig.json b/app/tsconfig.json similarity index 100% rename from tsconfig.json rename to app/tsconfig.json diff --git a/tsconfig.node.json b/app/tsconfig.node.json similarity index 100% rename from tsconfig.node.json rename to app/tsconfig.node.json diff --git a/vite.config.ts b/app/vite.config.ts similarity index 100% rename from vite.config.ts rename to app/vite.config.ts From 2551a398b95976a76f50c80cda13a943c7ba6304 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 20 Apr 2026 10:03:22 +0200 Subject: [PATCH 002/134] Init dotnet project --- .gitignore | 3 ++ AniStream.slnx | 5 ++ server/AniStream/AniStream.csproj | 14 +++++ server/AniStream/Program.cs | 51 +++++++++++++++++++ .../AniStream/Properties/launchSettings.json | 23 +++++++++ server/AniStream/appsettings.Development.json | 8 +++ server/AniStream/appsettings.json | 9 ++++ 7 files changed, 113 insertions(+) create mode 100644 AniStream.slnx create mode 100644 server/AniStream/AniStream.csproj create mode 100644 server/AniStream/Program.cs create mode 100644 server/AniStream/Properties/launchSettings.json create mode 100644 server/AniStream/appsettings.Development.json create mode 100644 server/AniStream/appsettings.json diff --git a/.gitignore b/.gitignore index ed95ae1..94e000a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ dist-ssr *.sln *.sw? +bin +obj + app/src/utils/i18n.ts \ No newline at end of file diff --git a/AniStream.slnx b/AniStream.slnx new file mode 100644 index 0000000..bc9b182 --- /dev/null +++ b/AniStream.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/server/AniStream/AniStream.csproj b/server/AniStream/AniStream.csproj new file mode 100644 index 0000000..f05654f --- /dev/null +++ b/server/AniStream/AniStream.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + AniStream.API + + + + + + + diff --git a/server/AniStream/Program.cs b/server/AniStream/Program.cs new file mode 100644 index 0000000..63b7f8e --- /dev/null +++ b/server/AniStream/Program.cs @@ -0,0 +1,51 @@ +namespace AniStream.API; + +public static class Program +{ + public static void Main(string[] args) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + + // Add services to the container. + // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi + builder.Services.AddOpenApi(); + + WebApplication app = builder.Build(); + + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi(); + } + + app.UseHttpsRedirection(); + + string[] summaries = new[] + { + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + }; + + app.MapGet("/weatherforecast", () => + { + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; + }) + .WithName("GetWeatherForecast"); + + app.Run(); + + } +} + + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/server/AniStream/Properties/launchSettings.json b/server/AniStream/Properties/launchSettings.json new file mode 100644 index 0000000..38fe9bb --- /dev/null +++ b/server/AniStream/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5281", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7102;http://localhost:5281", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/server/AniStream/appsettings.Development.json b/server/AniStream/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/server/AniStream/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/server/AniStream/appsettings.json b/server/AniStream/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/server/AniStream/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} From 627a77cc8148d097ef282bd26c7c2f4c77757591 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 20 Apr 2026 12:10:28 +0200 Subject: [PATCH 003/134] Implement basic API layout --- AniStream.slnx | 1 + .../AniStream.Services.csproj | 10 ++ server/AniStream.Services/Class1.cs | 6 ++ .../Contracts/ICredentialService.cs | 6 ++ server/AniStream/AniStream.csproj | 3 + .../Controllers/CredentialsController.cs | 53 ++++++++++ server/AniStream/Models/LoginModel.cs | 10 ++ server/AniStream/Program.cs | 97 +++++++++++++------ .../AniStream/Services/CredentialsService.cs | 17 ++++ server/AniStream/Utils/ApiControllerBase.cs | 16 +++ ...ptyStringEnabledDisplayMetadataProvider.cs | 14 +++ 11 files changed, 203 insertions(+), 30 deletions(-) create mode 100644 server/AniStream.Services/AniStream.Services.csproj create mode 100644 server/AniStream.Services/Class1.cs create mode 100644 server/AniStream.Services/Contracts/ICredentialService.cs create mode 100644 server/AniStream/Controllers/CredentialsController.cs create mode 100644 server/AniStream/Models/LoginModel.cs create mode 100644 server/AniStream/Services/CredentialsService.cs create mode 100644 server/AniStream/Utils/ApiControllerBase.cs create mode 100644 server/AniStream/Utils/EmptyStringEnabledDisplayMetadataProvider.cs diff --git a/AniStream.slnx b/AniStream.slnx index bc9b182..51fcedd 100644 --- a/AniStream.slnx +++ b/AniStream.slnx @@ -1,5 +1,6 @@ + diff --git a/server/AniStream.Services/AniStream.Services.csproj b/server/AniStream.Services/AniStream.Services.csproj new file mode 100644 index 0000000..fbb9a97 --- /dev/null +++ b/server/AniStream.Services/AniStream.Services.csproj @@ -0,0 +1,10 @@ + + + + net9.0 + enable + enable + AniStream + + + diff --git a/server/AniStream.Services/Class1.cs b/server/AniStream.Services/Class1.cs new file mode 100644 index 0000000..ff0d5f9 --- /dev/null +++ b/server/AniStream.Services/Class1.cs @@ -0,0 +1,6 @@ +namespace AniStream.Services; + +public class Class1 +{ + +} diff --git a/server/AniStream.Services/Contracts/ICredentialService.cs b/server/AniStream.Services/Contracts/ICredentialService.cs new file mode 100644 index 0000000..b41d0a4 --- /dev/null +++ b/server/AniStream.Services/Contracts/ICredentialService.cs @@ -0,0 +1,6 @@ +namespace AniStream.Contracts; + +public interface ICredentialsService +{ + public bool ValidateCredentials(string username, string password); +} \ No newline at end of file diff --git a/server/AniStream/AniStream.csproj b/server/AniStream/AniStream.csproj index f05654f..23c57c9 100644 --- a/server/AniStream/AniStream.csproj +++ b/server/AniStream/AniStream.csproj @@ -9,6 +9,9 @@ + + + diff --git a/server/AniStream/Controllers/CredentialsController.cs b/server/AniStream/Controllers/CredentialsController.cs new file mode 100644 index 0000000..4dde487 --- /dev/null +++ b/server/AniStream/Controllers/CredentialsController.cs @@ -0,0 +1,53 @@ +using System.Security.Claims; +using AniStream.API.Models; +using AniStream.API.Utils; +using AniStream.Contracts; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AniStream.API.Controllers; + + +[Route("api/credentials")] +[ApiController] +public class CredentialsController : ApiControllerBase +{ + public const string LOGIN_ROUTE = "/api/credentials/login"; + public const string LOGOUT_ROUTE = "/api/credentials/logout"; + + private ICredentialsService _credentialsService; + + public CredentialsController(ICredentialsService credentialsService) + { + _credentialsService = credentialsService; + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginModel credentials) + { + if (!_credentialsService.ValidateCredentials(credentials.Username, credentials.Password)) + { + return Unauthorized("Credentials are wrong"); + } + + List claims = new List + { + new Claim(ClaimTypes.Name, credentials.Username), + }; + ClaimsIdentity identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + ClaimsPrincipal principal = new ClaimsPrincipal(identity); + + await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); + return Ok(); + } + + [HttpGet("logout")] + [Authorize] + public async Task Logout() + { + await HttpContext.SignOutAsync(); + return Ok(); + } +} \ No newline at end of file diff --git a/server/AniStream/Models/LoginModel.cs b/server/AniStream/Models/LoginModel.cs new file mode 100644 index 0000000..af94fac --- /dev/null +++ b/server/AniStream/Models/LoginModel.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace AniStream.API.Models; + +public sealed class LoginModel +{ + public required string Username { get; set; } + + public required string Password { get; set; } +} \ No newline at end of file diff --git a/server/AniStream/Program.cs b/server/AniStream/Program.cs index 63b7f8e..265dc3f 100644 --- a/server/AniStream/Program.cs +++ b/server/AniStream/Program.cs @@ -1,3 +1,12 @@ +using Scalar.AspNetCore; +using AniStream.API.Utils; +using Microsoft.AspNetCore.Authentication.Cookies; +using AniStream.API.Controllers; +using AniStream.Contracts; +using AniStream.API.Serivces; +using Microsoft.OpenApi.Models; +using Microsoft.AspNetCore.Mvc; + namespace AniStream.API; public static class Program @@ -6,46 +15,74 @@ public static void Main(string[] args) { WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - // Add services to the container. - // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi - builder.Services.AddOpenApi(); + builder.Services.AddControllers(); + builder.Services.Configure(options => + { + options.ModelMetadataDetailsProviders.Add(new EmptyStringEnabledDisplayMetadataProvider()); + }); + builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = CredentialsController.LOGIN_ROUTE; + options.LogoutPath = CredentialsController.LOGOUT_ROUTE; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + + options.Events.OnRedirectToLogin = context => + { + if (context.Request.Path.StartsWithSegments("/api")) + { + context.Response.Clear(); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + } + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + }; + }); + builder.Services.AddAuthorization(); + builder.Services.AddOpenApi(options => + { + options.AddDocumentTransformer((document, context, cancellationToken) => + { + document.Components ??= new(); + document.Components.SecuritySchemes ??= new Dictionary(); + + document.Components.SecuritySchemes["cookieAuth"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Cookie, + Name = ".AspNetCore.Cookies" + }; + + return Task.CompletedTask; + }); + }); + builder.Services.AddEndpointsApiExplorer(); + SetupDependencyInjection(builder); WebApplication app = builder.Build(); - // Configure the HTTP request pipeline. - if (app.Environment.IsDevelopment()) + if (app.Environment.IsProduction()) { - app.MapOpenApi(); + app.UseHttpsRedirection(); } - app.UseHttpsRedirection(); - - string[] summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; + app.UseAuthentication(); + app.UseAuthorization(); - app.MapGet("/weatherforecast", () => + app.MapOpenApi(); + app.MapControllers(); + app.MapScalarApiReference(options => { - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; - }) - .WithName("GetWeatherForecast"); + options.Title = "AniStream API"; + }); app.Run(); - } -} - -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + private static void SetupDependencyInjection(WebApplicationBuilder builder) + { + builder.Services.AddSingleton(); + } } diff --git a/server/AniStream/Services/CredentialsService.cs b/server/AniStream/Services/CredentialsService.cs new file mode 100644 index 0000000..c594b7a --- /dev/null +++ b/server/AniStream/Services/CredentialsService.cs @@ -0,0 +1,17 @@ +using AniStream.Contracts; + +namespace AniStream.API.Serivces; + +public sealed class CredentialsService : ICredentialsService +{ + public bool ValidateCredentials(string username, string password) + { + // TODO connect with EF + if (username == "admin" && password == "admin") + { + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/server/AniStream/Utils/ApiControllerBase.cs b/server/AniStream/Utils/ApiControllerBase.cs new file mode 100644 index 0000000..f81771c --- /dev/null +++ b/server/AniStream/Utils/ApiControllerBase.cs @@ -0,0 +1,16 @@ + +using Microsoft.AspNetCore.Mvc; + +namespace AniStream.API.Utils; + +public abstract class ApiControllerBase : ControllerBase +{ + protected IActionResult Unauthorized(string message) + { + return Problem( + title: "Unauthorized", + detail: message, + statusCode: StatusCodes.Status401Unauthorized + ); + } +} \ No newline at end of file diff --git a/server/AniStream/Utils/EmptyStringEnabledDisplayMetadataProvider.cs b/server/AniStream/Utils/EmptyStringEnabledDisplayMetadataProvider.cs new file mode 100644 index 0000000..28b8a06 --- /dev/null +++ b/server/AniStream/Utils/EmptyStringEnabledDisplayMetadataProvider.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + +namespace AniStream.API.Utils; + +public class EmptyStringEnabledDisplayMetadataProvider : IDisplayMetadataProvider +{ + public void CreateDisplayMetadata(DisplayMetadataProviderContext context) + { + if (context.Key.ModelType == typeof(string)) + { + context.DisplayMetadata.ConvertEmptyStringToNull = false; + } + } +} \ No newline at end of file From 56effba1b34f16e910db3a0fd8a282be76f5939c Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 20 Apr 2026 13:22:46 +0200 Subject: [PATCH 004/134] Basic EFCore integration --- AniStream.slnx | 1 + .../AniStream.Models/AniStream.Models.csproj | 22 +++++++++++++++++ .../AniStream.Models/Models/AppDbContext.cs | 12 ++++++++++ .../AniStream.Models/Models/ProfileModel.cs | 4 ++++ .../AniStream.Services.csproj | 9 +++++++ server/AniStream.Services/Class1.cs | 6 ----- .../Contracts/IUserService.cs | 8 +++++++ .../Services/UserService.cs | 24 +++++++++++++++++++ server/AniStream/Program.cs | 1 + 9 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 server/AniStream.Models/AniStream.Models.csproj create mode 100644 server/AniStream.Models/Models/AppDbContext.cs create mode 100644 server/AniStream.Models/Models/ProfileModel.cs delete mode 100644 server/AniStream.Services/Class1.cs create mode 100644 server/AniStream.Services/Contracts/IUserService.cs create mode 100644 server/AniStream.Services/Services/UserService.cs diff --git a/AniStream.slnx b/AniStream.slnx index 51fcedd..925f581 100644 --- a/AniStream.slnx +++ b/AniStream.slnx @@ -1,5 +1,6 @@ + diff --git a/server/AniStream.Models/AniStream.Models.csproj b/server/AniStream.Models/AniStream.Models.csproj new file mode 100644 index 0000000..d70a3c4 --- /dev/null +++ b/server/AniStream.Models/AniStream.Models.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + AniStream + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/server/AniStream.Models/Models/AppDbContext.cs b/server/AniStream.Models/Models/AppDbContext.cs new file mode 100644 index 0000000..5fe6ac9 --- /dev/null +++ b/server/AniStream.Models/Models/AppDbContext.cs @@ -0,0 +1,12 @@ +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Profiles => Set(); +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/ProfileModel.cs b/server/AniStream.Models/Models/ProfileModel.cs new file mode 100644 index 0000000..22ffe24 --- /dev/null +++ b/server/AniStream.Models/Models/ProfileModel.cs @@ -0,0 +1,4 @@ +public class ProfileModel +{ + public int ProfileId { get; set; } +} \ No newline at end of file diff --git a/server/AniStream.Services/AniStream.Services.csproj b/server/AniStream.Services/AniStream.Services.csproj index fbb9a97..acf40ff 100644 --- a/server/AniStream.Services/AniStream.Services.csproj +++ b/server/AniStream.Services/AniStream.Services.csproj @@ -7,4 +7,13 @@ AniStream + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/server/AniStream.Services/Class1.cs b/server/AniStream.Services/Class1.cs deleted file mode 100644 index ff0d5f9..0000000 --- a/server/AniStream.Services/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace AniStream.Services; - -public class Class1 -{ - -} diff --git a/server/AniStream.Services/Contracts/IUserService.cs b/server/AniStream.Services/Contracts/IUserService.cs new file mode 100644 index 0000000..8c3ae84 --- /dev/null +++ b/server/AniStream.Services/Contracts/IUserService.cs @@ -0,0 +1,8 @@ +namespace AniStream.Contracts; + +public interface IUserService +{ + public Task GetActiveProfile(); + + public Task GetProfiles(); +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/UserService.cs b/server/AniStream.Services/Services/UserService.cs new file mode 100644 index 0000000..08314d4 --- /dev/null +++ b/server/AniStream.Services/Services/UserService.cs @@ -0,0 +1,24 @@ +using AniStream.Contracts; +using AniStream.Models; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Services; + +public class UserService : IUserService +{ + private AppDbContext _db; + public UserService(AppDbContext db) + { + _db = db; + } + + public Task GetActiveProfile() + { + throw new NotImplementedException(); + } + + public async Task GetProfiles() + { + return _db.Profiles.ToArray(); + } +} \ No newline at end of file diff --git a/server/AniStream/Program.cs b/server/AniStream/Program.cs index 265dc3f..6b253e7 100644 --- a/server/AniStream/Program.cs +++ b/server/AniStream/Program.cs @@ -60,6 +60,7 @@ public static void Main(string[] args) }); builder.Services.AddEndpointsApiExplorer(); SetupDependencyInjection(builder); + // TODO setup EFCore integration WebApplication app = builder.Build(); From 13f6a1874f07f7a8cb765494e7aa56643ee3f716 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 20 Apr 2026 17:08:56 +0200 Subject: [PATCH 005/134] Fix I18n code generation --- .github/workflows/CD.yml | 2 +- .github/workflows/CI.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 9bf8f2f..66615cc 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -37,7 +37,7 @@ jobs: run: npm ci - name: Run I18n generator - run: node i18n.gen.cjs + run: node app/i18n.gen.cjs - name: Signing key validation env: diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index aa86114..14bd453 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -27,7 +27,7 @@ jobs: npm ci - name: Run I18n generator - run: node i18n.gen.cjs + run: node app/i18n.gen.cjs - name: Build run: | From 167f394cb296295ef139ad500cdc2fc292c74b86 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 20 Apr 2026 17:37:45 +0200 Subject: [PATCH 006/134] Exclude migration to dedicated files --- .../standalone/db/metadata.service.ts | 135 ++---------------- .../services/standalone/db/user.service.ts | 45 +----- migration/sqlite/metadata/1.sql | 47 ++++++ migration/sqlite/metadata/2.sql | 6 + migration/sqlite/metadata/3.sql | 52 +++++++ migration/sqlite/metadata/4.sql | 15 ++ migration/sqlite/profile/1.sql | 14 ++ migration/sqlite/profile/2.sql | 31 ++++ 8 files changed, 179 insertions(+), 166 deletions(-) create mode 100644 migration/sqlite/metadata/1.sql create mode 100644 migration/sqlite/metadata/2.sql create mode 100644 migration/sqlite/metadata/3.sql create mode 100644 migration/sqlite/metadata/4.sql create mode 100644 migration/sqlite/profile/1.sql create mode 100644 migration/sqlite/profile/2.sql diff --git a/app/src/services/standalone/db/metadata.service.ts b/app/src/services/standalone/db/metadata.service.ts index adc02ee..09576c6 100644 --- a/app/src/services/standalone/db/metadata.service.ts +++ b/app/src/services/standalone/db/metadata.service.ts @@ -11,6 +11,11 @@ import {UserService} from "@contracts/user.contract"; import {ProfileModel} from "@models/profile.model"; +import sql1 from "@/../../migration/sqlite/metadata/1.sql?raw"; +import sql2 from "@/../../migration/sqlite/metadata/2.sql?raw"; +import sql3 from "@/../../migration/sqlite/metadata/3.sql?raw"; +import sql4 from "@/../../migration/sqlite/metadata/4.sql?raw"; + class MetadataDbServiceImpl implements MetadataDbService { private readonly userService: UserService; @@ -79,56 +84,7 @@ class DbVersion1 implements MetadataDbVersion { } public async migrate(session: DbSession, _userService: UserService, _provider: string): Promise { - // language=SQLite - await session.execute(` -PRAGMA user_version = 1; - -CREATE TABLE series -( - series_id INTEGER PRIMARY KEY AUTOINCREMENT, - guid TEXT UNIQUE NOT NULL, - title TEXT NOT NULL, - description TEXT NOT NULL, - preview_image TEXT UNIQUE -); - -CREATE TABLE season -( - season_id INTEGER PRIMARY KEY AUTOINCREMENT, - series_id INTEGER NOT NULL, - season_number INTEGER NOT NULL, - FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT -); - -CREATE TABLE episode -( - episode_id INTEGER PRIMARY KEY AUTOINCREMENT, - season_id INTEGER NOT NULL, - episode_number INTEGER NOT NULL, - german_title TEXT NOT NULL, - english_title TEXT NOT NULL, - description TEXT NOT NULL, - percentage_watched INTEGER NOT NULL, - stopped_time INTEGER NOT NULL, - FOREIGN KEY (season_id) REFERENCES season (season_id) ON DELETE RESTRICT -); - -CREATE TABLE genre -( - genre_id INTEGER PRIMARY KEY AUTOINCREMENT, - key TEXT UNIQUE NOT NULL -); - -CREATE TABLE genre_to_series -( - genre_to_series_id INTEGER PRIMARY KEY AUTOINCREMENT, - genre_id INTEGER NOT NULL, - series_id INTEGER NOT NULL, - main_genre BOOLEAN NOT NULL, - FOREIGN KEY (genre_id) REFERENCES genre (genre_id) ON DELETE RESTRICT, - FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT -); - `); + await session.execute(sql1); } } @@ -138,15 +94,7 @@ class DbVersion2 implements MetadataDbVersion { public version: number = 2; public async migrate(session: DbSession, _userService: UserService, _provider: string): Promise { - // language=SQLite - await session.execute(` -CREATE TABLE watchlist -( - watchlist_id INTEGER PRIMARY KEY AUTOINCREMENT, - series_id INTEGER UNIQUE NOT NULL, - FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT -); - `); + await session.execute(sql2); } } @@ -158,55 +106,7 @@ class DbVersion3 implements MetadataDbVersion { public async migrate(session: DbSession, userService: UserService, _provider: string): Promise { const profile: ProfileModel = await userService.getMigrationProfile(); - // language=SQLite - await session.execute(` -CREATE TABLE watchlist_new -( - watchlist_id INTEGER PRIMARY KEY AUTOINCREMENT, - series_id INTEGER UNIQUE NOT NULL, - tenant_id TEXT NOT NULL, - FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT -); - -INSERT INTO watchlist_new (watchlist_id, series_id, tenant_id) -SELECT watchlist_id, series_id, ? FROM watchlist; - -DROP TABLE watchlist; - -ALTER TABLE watchlist_new RENAME TO watchlist; - -CREATE TABLE episode_new -( - episode_id INTEGER PRIMARY KEY AUTOINCREMENT, - season_id INTEGER NOT NULL, - episode_number INTEGER NOT NULL, - german_title TEXT NOT NULL, - english_title TEXT NOT NULL, - description TEXT NOT NULL, - FOREIGN KEY (season_id) REFERENCES season (season_id) ON DELETE RESTRICT -); - -INSERT INTO episode_new (episode_id, season_id, episode_number, german_title, english_title, description) -SELECT episode_id, season_id, episode_number, german_title, english_title, description FROM episode; - -ALTER TABLE episode RENAME TO episode_old; -ALTER TABLE episode_new RENAME TO episode; - -CREATE TABLE watchtime -( - watchtime_id INTEGER PRIMARY KEY AUTOINCREMENT, - episode_id INTEGER NOT NULL, - percentage_watched INTEGER NOT NULL, - stopped_time INTEGER NOT NULL, - tenant_id TEXT NOT NULL, - FOREIGN KEY (episode_id) REFERENCES episode (episode_id) ON DELETE CASCADE -); - -INSERT INTO watchtime (episode_id, percentage_watched, stopped_time, tenant_id) -SELECT episode_id, percentage_watched, stopped_time, ? FROM episode_old; - -DROP TABLE episode_old; - `, profile.uuid, profile.uuid); + await session.execute(sql3, profile.uuid, profile.uuid); } } @@ -215,24 +115,7 @@ class DbVersion4 implements MetadataDbVersion { public version: number = 4; public async migrate(session: DbSession, _userService: UserService, _provider: string): Promise { - // language=SQLite - await session.execute(` -CREATE TABLE list -( - list_id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - tenant_id TEXT NOT NULL -); - -CREATE TABLE list_to_series -( - list_id INTEGER NOT NULL, - series_id INTEGER NOT NULL, - PRIMARY KEY (list_id, series_id), - FOREIGN KEY (list_id) REFERENCES list (list_id) ON DELETE CASCADE, - FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT -); - `); + await session.execute(sql4); } } diff --git a/app/src/services/standalone/db/user.service.ts b/app/src/services/standalone/db/user.service.ts index b7b4765..21d283e 100644 --- a/app/src/services/standalone/db/user.service.ts +++ b/app/src/services/standalone/db/user.service.ts @@ -5,6 +5,9 @@ import {UserDbService} from "@contracts/standalone/user.contract"; import {ServiceDeclaration} from "@services/declaration"; import {DbSession, DbVersion, DbVersionConstructor} from "@services/utils/db"; +import sql1 from "@/../../migration/sqlite/profile/1.sql?raw"; +import sql2 from "@/../../migration/sqlite/profile/2.sql?raw"; + export class UserDbServiceImpl implements UserDbService { public constructor() { } @@ -66,23 +69,7 @@ class DbVersion1 implements UserDbVersion { } public async migrate(session: DbSession): Promise { - // language=SQLite - await session.execute(` -PRAGMA user_version = 1; - -CREATE TABLE profile -( - profile_id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT UNIQUE NOT NULL, - name TEXT NOT NULL, - background_color TEXT NOT NULL, - eye TEXT NOT NULL, - mouth TEXT NOT NULL, - theme TEXT NOT NULL, - lang TEXT NOT NULL, - tos_accepted BOOLEAN NOT NULL -); - `); + await session.execute(sql1); } } @@ -94,29 +81,7 @@ class DbVersion2 implements UserDbVersion { } public async migrate(session: DbSession): Promise { - // language=SQLite - await session.execute(` -CREATE TABLE profile_new -( - profile_id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT UNIQUE NOT NULL, - name TEXT NOT NULL, - background_color TEXT NOT NULL, - eye TEXT NOT NULL, - mouth TEXT NOT NULL, - theme TEXT NOT NULL, - lang TEXT NOT NULL, - tos_accepted BOOLEAN NOT NULL, - sync_catalog BOOLEAN NOT NULL -); - -INSERT INTO profile_new (profile_id, uuid, name, background_color, eye, mouth, theme, lang, tos_accepted, sync_catalog) -SELECT profile_id, uuid, name, background_color, eye, mouth, theme, lang, tos_accepted, false FROM profile; - -DROP TABLE profile; - -ALTER TABLE profile_new RENAME TO profile; - `); + await session.execute(sql2); } } diff --git a/migration/sqlite/metadata/1.sql b/migration/sqlite/metadata/1.sql new file mode 100644 index 0000000..8118c1a --- /dev/null +++ b/migration/sqlite/metadata/1.sql @@ -0,0 +1,47 @@ +PRAGMA user_version = 1; + +CREATE TABLE series +( + series_id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + preview_image TEXT UNIQUE +); + +CREATE TABLE season +( + season_id INTEGER PRIMARY KEY AUTOINCREMENT, + series_id INTEGER NOT NULL, + season_number INTEGER NOT NULL, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); + +CREATE TABLE episode +( + episode_id INTEGER PRIMARY KEY AUTOINCREMENT, + season_id INTEGER NOT NULL, + episode_number INTEGER NOT NULL, + german_title TEXT NOT NULL, + english_title TEXT NOT NULL, + description TEXT NOT NULL, + percentage_watched INTEGER NOT NULL, + stopped_time INTEGER NOT NULL, + FOREIGN KEY (season_id) REFERENCES season (season_id) ON DELETE RESTRICT +); + +CREATE TABLE genre +( + genre_id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL +); + +CREATE TABLE genre_to_series +( + genre_to_series_id INTEGER PRIMARY KEY AUTOINCREMENT, + genre_id INTEGER NOT NULL, + series_id INTEGER NOT NULL, + main_genre BOOLEAN NOT NULL, + FOREIGN KEY (genre_id) REFERENCES genre (genre_id) ON DELETE RESTRICT, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); \ No newline at end of file diff --git a/migration/sqlite/metadata/2.sql b/migration/sqlite/metadata/2.sql new file mode 100644 index 0000000..d1e72f6 --- /dev/null +++ b/migration/sqlite/metadata/2.sql @@ -0,0 +1,6 @@ +CREATE TABLE watchlist +( + watchlist_id INTEGER PRIMARY KEY AUTOINCREMENT, + series_id INTEGER UNIQUE NOT NULL, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); \ No newline at end of file diff --git a/migration/sqlite/metadata/3.sql b/migration/sqlite/metadata/3.sql new file mode 100644 index 0000000..a44a74b --- /dev/null +++ b/migration/sqlite/metadata/3.sql @@ -0,0 +1,52 @@ +CREATE TABLE watchlist_new +( + watchlist_id INTEGER PRIMARY KEY AUTOINCREMENT, + series_id INTEGER UNIQUE NOT NULL, + tenant_id TEXT NOT NULL, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); + +INSERT INTO watchlist_new (watchlist_id, series_id, tenant_id) +SELECT watchlist_id, series_id, ? +FROM watchlist; + +DROP TABLE watchlist; + +ALTER TABLE watchlist_new + RENAME TO watchlist; + +CREATE TABLE episode_new +( + episode_id INTEGER PRIMARY KEY AUTOINCREMENT, + season_id INTEGER NOT NULL, + episode_number INTEGER NOT NULL, + german_title TEXT NOT NULL, + english_title TEXT NOT NULL, + description TEXT NOT NULL, + FOREIGN KEY (season_id) REFERENCES season (season_id) ON DELETE RESTRICT +); + +INSERT INTO episode_new (episode_id, season_id, episode_number, german_title, english_title, description) +SELECT episode_id, season_id, episode_number, german_title, english_title, description +FROM episode; + +ALTER TABLE episode + RENAME TO episode_old; +ALTER TABLE episode_new + RENAME TO episode; + +CREATE TABLE watchtime +( + watchtime_id INTEGER PRIMARY KEY AUTOINCREMENT, + episode_id INTEGER NOT NULL, + percentage_watched INTEGER NOT NULL, + stopped_time INTEGER NOT NULL, + tenant_id TEXT NOT NULL, + FOREIGN KEY (episode_id) REFERENCES episode (episode_id) ON DELETE CASCADE +); + +INSERT INTO watchtime (episode_id, percentage_watched, stopped_time, tenant_id) +SELECT episode_id, percentage_watched, stopped_time, ? +FROM episode_old; + +DROP TABLE episode_old; \ No newline at end of file diff --git a/migration/sqlite/metadata/4.sql b/migration/sqlite/metadata/4.sql new file mode 100644 index 0000000..cd0f95a --- /dev/null +++ b/migration/sqlite/metadata/4.sql @@ -0,0 +1,15 @@ +CREATE TABLE list +( + list_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + tenant_id TEXT NOT NULL +); + +CREATE TABLE list_to_series +( + list_id INTEGER NOT NULL, + series_id INTEGER NOT NULL, + PRIMARY KEY (list_id, series_id), + FOREIGN KEY (list_id) REFERENCES list (list_id) ON DELETE CASCADE, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); \ No newline at end of file diff --git a/migration/sqlite/profile/1.sql b/migration/sqlite/profile/1.sql new file mode 100644 index 0000000..9a7c850 --- /dev/null +++ b/migration/sqlite/profile/1.sql @@ -0,0 +1,14 @@ +PRAGMA user_version = 1; + +CREATE TABLE profile +( + profile_id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + background_color TEXT NOT NULL, + eye TEXT NOT NULL, + mouth TEXT NOT NULL, + theme TEXT NOT NULL, + lang TEXT NOT NULL, + tos_accepted BOOLEAN NOT NULL +); \ No newline at end of file diff --git a/migration/sqlite/profile/2.sql b/migration/sqlite/profile/2.sql new file mode 100644 index 0000000..b442ea1 --- /dev/null +++ b/migration/sqlite/profile/2.sql @@ -0,0 +1,31 @@ +CREATE TABLE profile_new +( + profile_id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + background_color TEXT NOT NULL, + eye TEXT NOT NULL, + mouth TEXT NOT NULL, + theme TEXT NOT NULL, + lang TEXT NOT NULL, + tos_accepted BOOLEAN NOT NULL, + sync_catalog BOOLEAN NOT NULL +); + +INSERT INTO profile_new (profile_id, uuid, name, background_color, eye, mouth, theme, lang, tos_accepted, sync_catalog) +SELECT profile_id, + uuid, + name, + background_color, + eye, + mouth, + theme, + lang, + tos_accepted, + false +FROM profile; + +DROP TABLE profile; + +ALTER TABLE profile_new + RENAME TO profile; \ No newline at end of file From 04cf25a02e49646599ea039c9406baee16923e2f Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 20 Apr 2026 22:00:43 +0200 Subject: [PATCH 007/134] Rename sqlite to standalone migration --- .../standalone/db/metadata.service.ts | 8 +++--- .../services/standalone/db/user.service.ts | 4 +-- .../{sqlite => standalone}/metadata/1.sql | 0 .../{sqlite => standalone}/metadata/2.sql | 0 .../{sqlite => standalone}/metadata/3.sql | 0 .../{sqlite => standalone}/metadata/4.sql | 0 .../{sqlite => standalone}/profile/1.sql | 0 .../{sqlite => standalone}/profile/2.sql | 0 .../AniStream.Models/Models/AppDbContext.cs | 12 --------- .../Services/UserService.cs | 24 ----------------- .../Services/UserServiceImpl.cs | 27 +++++++++++++++++++ 11 files changed, 33 insertions(+), 42 deletions(-) rename migration/{sqlite => standalone}/metadata/1.sql (100%) rename migration/{sqlite => standalone}/metadata/2.sql (100%) rename migration/{sqlite => standalone}/metadata/3.sql (100%) rename migration/{sqlite => standalone}/metadata/4.sql (100%) rename migration/{sqlite => standalone}/profile/1.sql (100%) rename migration/{sqlite => standalone}/profile/2.sql (100%) delete mode 100644 server/AniStream.Models/Models/AppDbContext.cs delete mode 100644 server/AniStream.Services/Services/UserService.cs create mode 100644 server/AniStream.Services/Services/UserServiceImpl.cs diff --git a/app/src/services/standalone/db/metadata.service.ts b/app/src/services/standalone/db/metadata.service.ts index 09576c6..b1a3bb7 100644 --- a/app/src/services/standalone/db/metadata.service.ts +++ b/app/src/services/standalone/db/metadata.service.ts @@ -11,10 +11,10 @@ import {UserService} from "@contracts/user.contract"; import {ProfileModel} from "@models/profile.model"; -import sql1 from "@/../../migration/sqlite/metadata/1.sql?raw"; -import sql2 from "@/../../migration/sqlite/metadata/2.sql?raw"; -import sql3 from "@/../../migration/sqlite/metadata/3.sql?raw"; -import sql4 from "@/../../migration/sqlite/metadata/4.sql?raw"; +import sql1 from "../../../../../migration/standalone/metadata/1.sql?raw"; +import sql2 from "../../../../../migration/standalone/metadata/2.sql?raw"; +import sql3 from "../../../../../migration/standalone/metadata/3.sql?raw"; +import sql4 from "../../../../../migration/standalone/metadata/4.sql?raw"; class MetadataDbServiceImpl implements MetadataDbService { private readonly userService: UserService; diff --git a/app/src/services/standalone/db/user.service.ts b/app/src/services/standalone/db/user.service.ts index 21d283e..74812b9 100644 --- a/app/src/services/standalone/db/user.service.ts +++ b/app/src/services/standalone/db/user.service.ts @@ -5,8 +5,8 @@ import {UserDbService} from "@contracts/standalone/user.contract"; import {ServiceDeclaration} from "@services/declaration"; import {DbSession, DbVersion, DbVersionConstructor} from "@services/utils/db"; -import sql1 from "@/../../migration/sqlite/profile/1.sql?raw"; -import sql2 from "@/../../migration/sqlite/profile/2.sql?raw"; +import sql1 from "../../../../../migration/standalone/profile/1.sql?raw"; +import sql2 from "../../../../../migration/standalone/profile/2.sql?raw"; export class UserDbServiceImpl implements UserDbService { public constructor() { diff --git a/migration/sqlite/metadata/1.sql b/migration/standalone/metadata/1.sql similarity index 100% rename from migration/sqlite/metadata/1.sql rename to migration/standalone/metadata/1.sql diff --git a/migration/sqlite/metadata/2.sql b/migration/standalone/metadata/2.sql similarity index 100% rename from migration/sqlite/metadata/2.sql rename to migration/standalone/metadata/2.sql diff --git a/migration/sqlite/metadata/3.sql b/migration/standalone/metadata/3.sql similarity index 100% rename from migration/sqlite/metadata/3.sql rename to migration/standalone/metadata/3.sql diff --git a/migration/sqlite/metadata/4.sql b/migration/standalone/metadata/4.sql similarity index 100% rename from migration/sqlite/metadata/4.sql rename to migration/standalone/metadata/4.sql diff --git a/migration/sqlite/profile/1.sql b/migration/standalone/profile/1.sql similarity index 100% rename from migration/sqlite/profile/1.sql rename to migration/standalone/profile/1.sql diff --git a/migration/sqlite/profile/2.sql b/migration/standalone/profile/2.sql similarity index 100% rename from migration/sqlite/profile/2.sql rename to migration/standalone/profile/2.sql diff --git a/server/AniStream.Models/Models/AppDbContext.cs b/server/AniStream.Models/Models/AppDbContext.cs deleted file mode 100644 index 5fe6ac9..0000000 --- a/server/AniStream.Models/Models/AppDbContext.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace AniStream.Models; - -public class AppDbContext : DbContext -{ - public AppDbContext(DbContextOptions options) : base(options) - { - } - - public DbSet Profiles => Set(); -} \ No newline at end of file diff --git a/server/AniStream.Services/Services/UserService.cs b/server/AniStream.Services/Services/UserService.cs deleted file mode 100644 index 08314d4..0000000 --- a/server/AniStream.Services/Services/UserService.cs +++ /dev/null @@ -1,24 +0,0 @@ -using AniStream.Contracts; -using AniStream.Models; -using Microsoft.EntityFrameworkCore; - -namespace AniStream.Services; - -public class UserService : IUserService -{ - private AppDbContext _db; - public UserService(AppDbContext db) - { - _db = db; - } - - public Task GetActiveProfile() - { - throw new NotImplementedException(); - } - - public async Task GetProfiles() - { - return _db.Profiles.ToArray(); - } -} \ No newline at end of file diff --git a/server/AniStream.Services/Services/UserServiceImpl.cs b/server/AniStream.Services/Services/UserServiceImpl.cs new file mode 100644 index 0000000..31dd8e7 --- /dev/null +++ b/server/AniStream.Services/Services/UserServiceImpl.cs @@ -0,0 +1,27 @@ +using AniStream.Contexts; +using AniStream.Contracts; + +namespace AniStream.Services; + +public class UserServiceImpl : IUserService +{ + private readonly ProfileDbContextFactory _dbFactory; + + + public UserServiceImpl(ProfileDbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public Task GetActiveProfile() + { + throw new NotImplementedException(); + } + + public async Task GetProfiles() + { + await using ProfileDbContext db = _dbFactory.GetContext(); + + return db.Profiles.ToArray(); + } +} \ No newline at end of file From 3be48b949be7b0681a257fd88777f0bcac2db569 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 20 Apr 2026 22:01:06 +0200 Subject: [PATCH 008/134] Add sqlite migration --- migration/sqlite/metadata/1.sql | 0 migration/sqlite/profile/1.sql | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 migration/sqlite/metadata/1.sql create mode 100644 migration/sqlite/profile/1.sql diff --git a/migration/sqlite/metadata/1.sql b/migration/sqlite/metadata/1.sql new file mode 100644 index 0000000..e69de29 diff --git a/migration/sqlite/profile/1.sql b/migration/sqlite/profile/1.sql new file mode 100644 index 0000000..e69de29 From f653765c4f41e6bf0d0ded11534f4b6c333a025c Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 20 Apr 2026 22:01:42 +0200 Subject: [PATCH 009/134] Continue work on basic rest API --- .gitignore | 1 + .../AniStream.Models/AniStream.Models.csproj | 2 +- .../Contexts/MetadataDbContext.cs | 34 +++ .../Contexts/ProfileDbContext.cs | 34 +++ .../Migrators/SqliteMigrator.cs | 57 +++++ .../AniStream.Models/Models/ProfileModel.cs | 23 ++- .../Utils/DbContextFactory.cs | 80 +++++++ server/AniStream.Models/Utils/IDbMigrator.cs | 37 ++++ .../Contracts/ICredentialService.cs | 2 +- .../Contracts/IUserService.cs | 2 + .../AniStream.Services/Services/AutoLoader.cs | 12 ++ .../Services/UserServiceImpl.cs | 11 +- server/AniStream/AppConfig.cs | 35 ++++ .../Controllers/CredentialsController.cs | 5 +- server/AniStream/Program.cs | 195 ++++++++++-------- .../AniStream/Properties/launchSettings.json | 40 ++-- .../AniStream/Services/CredentialsService.cs | 18 +- server/AniStream/appsettings.Development.json | 10 +- server/AniStream/appsettings.json | 14 +- 19 files changed, 479 insertions(+), 133 deletions(-) create mode 100644 server/AniStream.Models/Contexts/MetadataDbContext.cs create mode 100644 server/AniStream.Models/Contexts/ProfileDbContext.cs create mode 100644 server/AniStream.Models/Migrators/SqliteMigrator.cs create mode 100644 server/AniStream.Models/Utils/DbContextFactory.cs create mode 100644 server/AniStream.Models/Utils/IDbMigrator.cs create mode 100644 server/AniStream.Services/Services/AutoLoader.cs create mode 100644 server/AniStream/AppConfig.cs diff --git a/.gitignore b/.gitignore index 94e000a..2073fd9 100644 --- a/.gitignore +++ b/.gitignore @@ -24,5 +24,6 @@ dist-ssr bin obj +tmp app/src/utils/i18n.ts \ No newline at end of file diff --git a/server/AniStream.Models/AniStream.Models.csproj b/server/AniStream.Models/AniStream.Models.csproj index d70a3c4..e22a762 100644 --- a/server/AniStream.Models/AniStream.Models.csproj +++ b/server/AniStream.Models/AniStream.Models.csproj @@ -12,7 +12,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/server/AniStream.Models/Contexts/MetadataDbContext.cs b/server/AniStream.Models/Contexts/MetadataDbContext.cs new file mode 100644 index 0000000..d22ae3f --- /dev/null +++ b/server/AniStream.Models/Contexts/MetadataDbContext.cs @@ -0,0 +1,34 @@ +using AniStream.Utils; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Contexts; + +public sealed class MetadataDbContext : DbContext +{ + public MetadataDbContext(DbContextOptions options) : base(options) + { + } +} + +public sealed class MetadataDbContextFactory : DbContextFactory +{ + private readonly string _connectionString; + + public MetadataDbContextFactory(string dbType, string migrationFolder, string connectionString) : base(dbType, migrationFolder, "metadata") + { + _connectionString = connectionString; + } + + public async Task GetContext(string providerName) + { + string actualConnString = String.Format(_connectionString, providerName); + + DbContextOptionsBuilder builder = new DbContextOptionsBuilder(); + UseDbVariant(builder, actualConnString); + + MetadataDbContext context = new MetadataDbContext(builder.Options); + await MigrateDatabaseIfRequired(context); + + return context; + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Contexts/ProfileDbContext.cs b/server/AniStream.Models/Contexts/ProfileDbContext.cs new file mode 100644 index 0000000..a707e16 --- /dev/null +++ b/server/AniStream.Models/Contexts/ProfileDbContext.cs @@ -0,0 +1,34 @@ +using AniStream.Utils; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Contexts; + +public sealed class ProfileDbContext : DbContext +{ + public ProfileDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Profiles { get; set; } +} + +public sealed class ProfileDbContextFactory : DbContextFactory +{ + private readonly string _connectionString; + + public ProfileDbContextFactory(string dbType, string migrationFolder, string connectionString) : base(dbType, migrationFolder, "profile") + { + _connectionString = connectionString; + } + + public async Task GetContext() + { + DbContextOptionsBuilder builder = new DbContextOptionsBuilder(); + UseDbVariant(builder, _connectionString); + + ProfileDbContext context = new ProfileDbContext(builder.Options); + await MigrateDatabaseIfRequired(context); + + return context; + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Migrators/SqliteMigrator.cs b/server/AniStream.Models/Migrators/SqliteMigrator.cs new file mode 100644 index 0000000..aeee5f8 --- /dev/null +++ b/server/AniStream.Models/Migrators/SqliteMigrator.cs @@ -0,0 +1,57 @@ +using System.Data; +using AniStream.Utils; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Migrators; + +public sealed class SqliteMigrator : DbMigrator +{ + public SqliteMigrator(string migrationPath, string databaseSchema) : base(migrationPath, databaseSchema, "sqlite2") + { + } + + public override async Task GetCurrentVersion(DbContext context) + { + if (!CheckDbFileExists(context)) + { + return 0; + } + + int version = await context.Database.SqlQuery($"PRAGMA user_version;").SingleAsync(); + return version; + } + + public override async Task Migrate(DbContext context, int fromVersion) + { + SqliteConnection connection = (SqliteConnection)context.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + { + connection.Open(); + } + + while (fromVersion < DbContextFactory.SchemaVersion) + { + fromVersion++; + string sql = ReadMigrationFile(fromVersion); + + await using (SqliteCommand command = connection.CreateCommand()) + { + command.CommandText = sql; + await command.ExecuteNonQueryAsync(); + } + + await using (SqliteCommand command = connection.CreateCommand()) + { + command.CommandText = $"PRAGMA user_version = {fromVersion};"; + await command.ExecuteNonQueryAsync(); + } + } + } + + private bool CheckDbFileExists(DbContext context) + { + SqliteConnection connection = (SqliteConnection)context.Database.GetDbConnection(); + return File.Exists(connection.DataSource); + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/ProfileModel.cs b/server/AniStream.Models/Models/ProfileModel.cs index 22ffe24..ca63e89 100644 --- a/server/AniStream.Models/Models/ProfileModel.cs +++ b/server/AniStream.Models/Models/ProfileModel.cs @@ -1,4 +1,25 @@ +using Microsoft.EntityFrameworkCore; + +[PrimaryKey(nameof(ProfileId))] public class ProfileModel { - public int ProfileId { get; set; } + public int ProfileId { get; init; } + + public string Uuid { get; init; } + + public string Name { get; init; } + + public string BackgroundColor { get; init; } + + public string Eye { get; init; } + + public string Mouth { get; init; } + + public string Theme { get; init; } + + public string Lang { get; init; } + + public bool TosAccepted { get; init; } + + public bool SyncCatalog { get; init; } } \ No newline at end of file diff --git a/server/AniStream.Models/Utils/DbContextFactory.cs b/server/AniStream.Models/Utils/DbContextFactory.cs new file mode 100644 index 0000000..4ce8c9b --- /dev/null +++ b/server/AniStream.Models/Utils/DbContextFactory.cs @@ -0,0 +1,80 @@ +using AniStream.Migrators; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Utils; + +public abstract class DbContextFactory where T : DbContext +{ + public const int SchemaVersion = 1; + + private readonly DbType _dbType; + private readonly string _migrationFolder; + private readonly string _databaseSchema; + + protected DbContextFactory(string dbType, string migrationFolder, string databaseSchema) + { + if (!Directory.Exists(migrationFolder)) + { + throw new ArgumentException($"Migration folder '{migrationFolder}' does not exist", nameof(migrationFolder)); + } + + if (databaseSchema != "metadata" && databaseSchema != "profile") + { + throw new ArgumentException($"Unknown database schema '{databaseSchema}', must be 'profile' or 'metadata'", nameof(databaseSchema)); + } + + _migrationFolder = migrationFolder; + _databaseSchema = databaseSchema; + + switch (dbType.ToLower()) + { + case "sqlite": + _dbType = DbType.Sqlite; + break; + default: + throw new ArgumentException($"Unknown database driver '{_dbType}'", nameof(dbType)); + } + } + + protected void UseDbVariant(DbContextOptionsBuilder builder, string connectionString) + { + switch (_dbType) + { + case DbType.Sqlite: + builder.UseSqlite(connectionString); + break; + default: + throw new InvalidOperationException($"Cannot setup EF for '{_dbType}'"); + + } + } + + protected async Task MigrateDatabaseIfRequired(T context) + { + DbMigrator migrator = GetMigrator(); + int currentVersion = await migrator.GetCurrentVersion(context); + if (currentVersion > SchemaVersion) + { + throw new Exception($"Database schema is newer than expected ({currentVersion} > {SchemaVersion})"); + } + + if (currentVersion < SchemaVersion) + { + await migrator.Migrate(context, currentVersion); + } + } + + private DbMigrator GetMigrator() + { + return _dbType switch + { + DbType.Sqlite => new SqliteMigrator(_migrationFolder, _databaseSchema), + _ => throw new InvalidOperationException($"Cannot find migrator for '{_dbType}'") + }; + } + + private enum DbType + { + Sqlite + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Utils/IDbMigrator.cs b/server/AniStream.Models/Utils/IDbMigrator.cs new file mode 100644 index 0000000..b0eb360 --- /dev/null +++ b/server/AniStream.Models/Utils/IDbMigrator.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Utils; + +public abstract class DbMigrator +{ + private readonly string _driverKeyword; + private readonly string _databaseSchema; + private readonly string _migrationPath; + + protected DbMigrator(string migrationPath, string databaseSchema, string driverKeyword) + { + _migrationPath = migrationPath; + _databaseSchema = databaseSchema; + _driverKeyword = driverKeyword; + } + + public abstract Task GetCurrentVersion(DbContext context); + + public abstract Task Migrate(DbContext context, int fromVersion); + + protected string ReadMigrationFile(int version) + { + if (version <= 0) + { + throw new ArgumentOutOfRangeException(nameof(version), "Version must be positive"); + } + + string fullPath = Path.Join(_migrationPath, _driverKeyword, _databaseSchema, $"{version}.sql"); + if (!File.Exists(fullPath)) + { + throw new ArgumentException($"Migration file not found for version '{version}'"); + } + + return File.ReadAllText(fullPath); + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/ICredentialService.cs b/server/AniStream.Services/Contracts/ICredentialService.cs index b41d0a4..61474c6 100644 --- a/server/AniStream.Services/Contracts/ICredentialService.cs +++ b/server/AniStream.Services/Contracts/ICredentialService.cs @@ -2,5 +2,5 @@ namespace AniStream.Contracts; public interface ICredentialsService { - public bool ValidateCredentials(string username, string password); + public Task ValidateCredentials(string username, string password); } \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/IUserService.cs b/server/AniStream.Services/Contracts/IUserService.cs index 8c3ae84..ab688db 100644 --- a/server/AniStream.Services/Contracts/IUserService.cs +++ b/server/AniStream.Services/Contracts/IUserService.cs @@ -5,4 +5,6 @@ public interface IUserService public Task GetActiveProfile(); public Task GetProfiles(); + + public Task GetProfileByUsernameOrDefault(string username); } \ No newline at end of file diff --git a/server/AniStream.Services/Services/AutoLoader.cs b/server/AniStream.Services/Services/AutoLoader.cs new file mode 100644 index 0000000..fb704c9 --- /dev/null +++ b/server/AniStream.Services/Services/AutoLoader.cs @@ -0,0 +1,12 @@ +using AniStream.Contracts; +using Microsoft.Extensions.DependencyInjection; + +namespace AniStream.Services; + +public sealed class AutoLoader +{ + public static void LoadServices(IServiceCollection services) + { + services.AddSingleton(); + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/UserServiceImpl.cs b/server/AniStream.Services/Services/UserServiceImpl.cs index 31dd8e7..c931876 100644 --- a/server/AniStream.Services/Services/UserServiceImpl.cs +++ b/server/AniStream.Services/Services/UserServiceImpl.cs @@ -1,5 +1,6 @@ using AniStream.Contexts; using AniStream.Contracts; +using Microsoft.EntityFrameworkCore; namespace AniStream.Services; @@ -20,8 +21,16 @@ public Task GetActiveProfile() public async Task GetProfiles() { - await using ProfileDbContext db = _dbFactory.GetContext(); + await using ProfileDbContext db = await _dbFactory.GetContext(); return db.Profiles.ToArray(); } + + public async Task GetProfileByUsernameOrDefault(string username) + { + await using ProfileDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from profile in db.Profiles where profile.Name == username select profile; + return await query.FirstOrDefaultAsync(); + } } \ No newline at end of file diff --git a/server/AniStream/AppConfig.cs b/server/AniStream/AppConfig.cs new file mode 100644 index 0000000..3abc999 --- /dev/null +++ b/server/AniStream/AppConfig.cs @@ -0,0 +1,35 @@ +namespace AniStream.API; + +public sealed class AppConfig +{ + public static AppConfig CurrentConfig { get; private set; } + + public readonly string MigrationPath; + public readonly string DatabaseDriver; + public readonly string DatabaseMetadataConnectionString; + public readonly string DatabaseProfileConnectionString; + + private AppConfig() + { + MigrationPath = GetEnvironmentVariable("DATABASE_MIGRATION_PATH"); + DatabaseDriver = GetEnvironmentVariable("DATABASE_DRIVER"); + DatabaseMetadataConnectionString = GetEnvironmentVariable("DATABASE_METADATA_CONNECTION_STRING"); + DatabaseProfileConnectionString = GetEnvironmentVariable("DATABASE_PROFILE_CONNECTION_STRING"); + } + + private string GetEnvironmentVariable(string name) + { + return Environment.GetEnvironmentVariable(name) ?? throw new Exception($"Missing environment variable: '{name}'"); + } + + private string GetEnvironmentVariableOrDefault(string name, string defaultValue) + { + return Environment.GetEnvironmentVariable(name) ?? defaultValue; + } + + public static void Initialize() + { + CurrentConfig = new AppConfig(); + Console.WriteLine($"Migration path: '{CurrentConfig.MigrationPath}'"); + } +} \ No newline at end of file diff --git a/server/AniStream/Controllers/CredentialsController.cs b/server/AniStream/Controllers/CredentialsController.cs index 4dde487..394171b 100644 --- a/server/AniStream/Controllers/CredentialsController.cs +++ b/server/AniStream/Controllers/CredentialsController.cs @@ -9,10 +9,9 @@ namespace AniStream.API.Controllers; - [Route("api/credentials")] [ApiController] -public class CredentialsController : ApiControllerBase +public class CredentialsController : ApiControllerBase { public const string LOGIN_ROUTE = "/api/credentials/login"; public const string LOGOUT_ROUTE = "/api/credentials/logout"; @@ -27,7 +26,7 @@ public CredentialsController(ICredentialsService credentialsService) [HttpPost("login")] public async Task Login([FromBody] LoginModel credentials) { - if (!_credentialsService.ValidateCredentials(credentials.Username, credentials.Password)) + if (!await _credentialsService.ValidateCredentials(credentials.Username, credentials.Password)) { return Unauthorized("Credentials are wrong"); } diff --git a/server/AniStream/Program.cs b/server/AniStream/Program.cs index 6b253e7..a2b4e8f 100644 --- a/server/AniStream/Program.cs +++ b/server/AniStream/Program.cs @@ -1,89 +1,106 @@ -using Scalar.AspNetCore; -using AniStream.API.Utils; -using Microsoft.AspNetCore.Authentication.Cookies; -using AniStream.API.Controllers; -using AniStream.Contracts; -using AniStream.API.Serivces; -using Microsoft.OpenApi.Models; -using Microsoft.AspNetCore.Mvc; - -namespace AniStream.API; - -public static class Program -{ - public static void Main(string[] args) - { - WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - - builder.Services.AddControllers(); - builder.Services.Configure(options => - { - options.ModelMetadataDetailsProviders.Add(new EmptyStringEnabledDisplayMetadataProvider()); - }); - builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(options => - { - options.LoginPath = CredentialsController.LOGIN_ROUTE; - options.LogoutPath = CredentialsController.LOGOUT_ROUTE; - options.Cookie.HttpOnly = true; - options.Cookie.SameSite = SameSiteMode.Lax; - - options.Events.OnRedirectToLogin = context => - { - if (context.Request.Path.StartsWithSegments("/api")) - { - context.Response.Clear(); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - return Task.CompletedTask; - } - context.Response.Redirect(context.RedirectUri); - return Task.CompletedTask; - }; - }); - builder.Services.AddAuthorization(); - builder.Services.AddOpenApi(options => - { - options.AddDocumentTransformer((document, context, cancellationToken) => - { - document.Components ??= new(); - document.Components.SecuritySchemes ??= new Dictionary(); - - document.Components.SecuritySchemes["cookieAuth"] = new OpenApiSecurityScheme - { - Type = SecuritySchemeType.ApiKey, - In = ParameterLocation.Cookie, - Name = ".AspNetCore.Cookies" - }; - - return Task.CompletedTask; - }); - }); - builder.Services.AddEndpointsApiExplorer(); - SetupDependencyInjection(builder); - // TODO setup EFCore integration - - WebApplication app = builder.Build(); - - if (app.Environment.IsProduction()) - { - app.UseHttpsRedirection(); - } - - app.UseAuthentication(); - app.UseAuthorization(); - - app.MapOpenApi(); - app.MapControllers(); - app.MapScalarApiReference(options => - { - options.Title = "AniStream API"; - }); - - app.Run(); - } - - private static void SetupDependencyInjection(WebApplicationBuilder builder) - { - builder.Services.AddSingleton(); - } -} +using Scalar.AspNetCore; +using AniStream.API.Utils; +using Microsoft.AspNetCore.Authentication.Cookies; +using AniStream.API.Controllers; +using AniStream.Contracts; +using AniStream.API.Serivces; +using AniStream.Contexts; +using AniStream.Services; +using Microsoft.OpenApi.Models; +using Microsoft.AspNetCore.Mvc; + +namespace AniStream.API; + +public static class Program +{ + public static void Main(string[] args) + { + AppConfig.Initialize(); + + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + + builder.Services.AddControllers(); + builder.Services.Configure(options => { options.ModelMetadataDetailsProviders.Add(new EmptyStringEnabledDisplayMetadataProvider()); }); + builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = CredentialsController.LOGIN_ROUTE; + options.LogoutPath = CredentialsController.LOGOUT_ROUTE; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + + options.Events.OnRedirectToLogin = context => + { + if (context.Request.Path.StartsWithSegments("/api")) + { + context.Response.Clear(); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + } + + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + }; + }); + builder.Services.AddAuthorization(); + builder.Services.AddOpenApi(options => + { + options.AddDocumentTransformer((document, context, cancellationToken) => + { + document.Components ??= new(); + document.Components.SecuritySchemes ??= new Dictionary(); + + document.Components.SecuritySchemes["cookieAuth"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Cookie, + Name = ".AspNetCore.Cookies" + }; + + return Task.CompletedTask; + }); + }); + builder.Services.AddEndpointsApiExplorer(); + SetupDependencyInjection(builder); + // TODO setup EFCore integration + + WebApplication app = builder.Build(); + + if (app.Environment.IsProduction()) + { + app.UseHttpsRedirection(); + } + + app.UseAuthentication(); + app.UseAuthorization(); + + app.MapOpenApi(); + app.MapControllers(); + app.MapScalarApiReference(options => { options.Title = "AniStream API"; }); + + app.Run(); + } + + private static void SetupDependencyInjection(WebApplicationBuilder builder) + { + // DB Contexts + builder.Services.AddSingleton( + new ProfileDbContextFactory( + AppConfig.CurrentConfig.DatabaseDriver, + AppConfig.CurrentConfig.MigrationPath, + AppConfig.CurrentConfig.DatabaseProfileConnectionString) + ); + builder.Services.AddSingleton( + new MetadataDbContextFactory( + AppConfig.CurrentConfig.DatabaseDriver, + AppConfig.CurrentConfig.MigrationPath, + AppConfig.CurrentConfig.DatabaseMetadataConnectionString) + ); + + // Proprietary services + builder.Services.AddSingleton(); + + // BL Layer + AutoLoader.LoadServices(builder.Services); + } +} \ No newline at end of file diff --git a/server/AniStream/Properties/launchSettings.json b/server/AniStream/Properties/launchSettings.json index 38fe9bb..c821b14 100644 --- a/server/AniStream/Properties/launchSettings.json +++ b/server/AniStream/Properties/launchSettings.json @@ -1,23 +1,23 @@ { - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5281", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7102;http://localhost:5281", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5281", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7102;http://localhost:5281", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } } - } } diff --git a/server/AniStream/Services/CredentialsService.cs b/server/AniStream/Services/CredentialsService.cs index c594b7a..fe46ed5 100644 --- a/server/AniStream/Services/CredentialsService.cs +++ b/server/AniStream/Services/CredentialsService.cs @@ -4,14 +4,22 @@ namespace AniStream.API.Serivces; public sealed class CredentialsService : ICredentialsService { - public bool ValidateCredentials(string username, string password) + private readonly IUserService _userService; + + public CredentialsService(IUserService userService) + { + _userService = userService; + } + + public async Task ValidateCredentials(string username, string password) { - // TODO connect with EF - if (username == "admin" && password == "admin") + ProfileModel? profile = await _userService.GetProfileByUsernameOrDefault(username); + if (profile is null) { - return true; + return false; } - return false; + // TODO check password + return true; } } \ No newline at end of file diff --git a/server/AniStream/appsettings.Development.json b/server/AniStream/appsettings.Development.json index ff66ba6..0e7f4af 100644 --- a/server/AniStream/appsettings.Development.json +++ b/server/AniStream/appsettings.Development.json @@ -1,8 +1,8 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } } - } } diff --git a/server/AniStream/appsettings.json b/server/AniStream/appsettings.json index 4d56694..a9a1bac 100644 --- a/server/AniStream/appsettings.json +++ b/server/AniStream/appsettings.json @@ -1,9 +1,9 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" } From 9e6fac67ef45bf839bb122bc521ae136a1f69104 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Tue, 21 Apr 2026 21:17:58 +0200 Subject: [PATCH 010/134] Setup sqlite schema --- AniStream.sln.DotSettings.user | 3 + migration/sqlite/metadata/1.sql | 77 +++++++++++++++++++ migration/sqlite/profile/1.sql | 13 ++++ .../Contexts/MetadataDbContext.cs | 5 ++ .../Contexts/ProfileDbContext.cs | 5 ++ .../Migrators/SqliteMigrator.cs | 10 ++- .../AniStream.Models/Models/ProfileModel.cs | 2 + server/AniStream.Models/Utils/IDbMigrator.cs | 4 +- .../Utils/SnakeCaseConvention.cs | 37 +++++++++ 9 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 AniStream.sln.DotSettings.user create mode 100644 server/AniStream.Models/Utils/SnakeCaseConvention.cs diff --git a/AniStream.sln.DotSettings.user b/AniStream.sln.DotSettings.user new file mode 100644 index 0000000..d6f8bfa --- /dev/null +++ b/AniStream.sln.DotSettings.user @@ -0,0 +1,3 @@ + + ForceIncluded + ForceIncluded \ No newline at end of file diff --git a/migration/sqlite/metadata/1.sql b/migration/sqlite/metadata/1.sql index e69de29..d708176 100644 --- a/migration/sqlite/metadata/1.sql +++ b/migration/sqlite/metadata/1.sql @@ -0,0 +1,77 @@ +CREATE TABLE episode +( + episode_id INTEGER PRIMARY KEY AUTOINCREMENT, + season_id INTEGER NOT NULL, + episode_number INTEGER NOT NULL, + german_title TEXT NOT NULL, + english_title TEXT NOT NULL, + description TEXT NOT NULL, + FOREIGN KEY (season_id) REFERENCES season (season_id) ON DELETE RESTRICT +); + +CREATE TABLE genre +( + genre_id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL +); + +CREATE TABLE genre_to_series +( + genre_to_series_id INTEGER PRIMARY KEY AUTOINCREMENT, + genre_id INTEGER NOT NULL, + series_id INTEGER NOT NULL, + main_genre BOOLEAN NOT NULL, + FOREIGN KEY (genre_id) REFERENCES genre (genre_id) ON DELETE RESTRICT, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); + +CREATE TABLE list +( + list_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + tenant_id TEXT NOT NULL +); + +CREATE TABLE list_to_series +( + list_id INTEGER NOT NULL, + series_id INTEGER NOT NULL, + PRIMARY KEY (list_id, series_id), + FOREIGN KEY (list_id) REFERENCES list (list_id) ON DELETE CASCADE, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); + +CREATE TABLE season +( + season_id INTEGER PRIMARY KEY AUTOINCREMENT, + series_id INTEGER NOT NULL, + season_number INTEGER NOT NULL, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); + +CREATE TABLE series +( + series_id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + preview_image TEXT UNIQUE +); + +CREATE TABLE watchlist +( + watchlist_id INTEGER PRIMARY KEY AUTOINCREMENT, + series_id INTEGER UNIQUE NOT NULL, + tenant_id TEXT NOT NULL, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); + +CREATE TABLE watchtime +( + watchtime_id INTEGER PRIMARY KEY AUTOINCREMENT, + episode_id INTEGER NOT NULL, + percentage_watched INTEGER NOT NULL, + stopped_time INTEGER NOT NULL, + tenant_id TEXT NOT NULL, + FOREIGN KEY (episode_id) REFERENCES episode (episode_id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/migration/sqlite/profile/1.sql b/migration/sqlite/profile/1.sql index e69de29..aeee5b1 100644 --- a/migration/sqlite/profile/1.sql +++ b/migration/sqlite/profile/1.sql @@ -0,0 +1,13 @@ +CREATE TABLE profile +( + profile_id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + background_color TEXT NOT NULL, + eye TEXT NOT NULL, + mouth TEXT NOT NULL, + theme TEXT NOT NULL, + lang TEXT NOT NULL, + tos_accepted BOOLEAN NOT NULL, + sync_catalog BOOLEAN NOT NULL +) \ No newline at end of file diff --git a/server/AniStream.Models/Contexts/MetadataDbContext.cs b/server/AniStream.Models/Contexts/MetadataDbContext.cs index d22ae3f..501be33 100644 --- a/server/AniStream.Models/Contexts/MetadataDbContext.cs +++ b/server/AniStream.Models/Contexts/MetadataDbContext.cs @@ -5,6 +5,11 @@ namespace AniStream.Contexts; public sealed class MetadataDbContext : DbContext { + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder.Conventions.Add(_ => new SnakeCaseConvention()); + } + public MetadataDbContext(DbContextOptions options) : base(options) { } diff --git a/server/AniStream.Models/Contexts/ProfileDbContext.cs b/server/AniStream.Models/Contexts/ProfileDbContext.cs index a707e16..89f04da 100644 --- a/server/AniStream.Models/Contexts/ProfileDbContext.cs +++ b/server/AniStream.Models/Contexts/ProfileDbContext.cs @@ -5,6 +5,11 @@ namespace AniStream.Contexts; public sealed class ProfileDbContext : DbContext { + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder.Conventions.Add(_ => new SnakeCaseConvention()); + } + public ProfileDbContext(DbContextOptions options) : base(options) { } diff --git a/server/AniStream.Models/Migrators/SqliteMigrator.cs b/server/AniStream.Models/Migrators/SqliteMigrator.cs index aeee5f8..724c53d 100644 --- a/server/AniStream.Models/Migrators/SqliteMigrator.cs +++ b/server/AniStream.Models/Migrators/SqliteMigrator.cs @@ -7,7 +7,7 @@ namespace AniStream.Migrators; public sealed class SqliteMigrator : DbMigrator { - public SqliteMigrator(string migrationPath, string databaseSchema) : base(migrationPath, databaseSchema, "sqlite2") + public SqliteMigrator(string migrationPath, string databaseSchema) : base(migrationPath, databaseSchema, "sqlite") { } @@ -50,8 +50,14 @@ public override async Task Migrate(DbContext context, int fromVersion) } private bool CheckDbFileExists(DbContext context) + { + string dbFile = GetDatabaseFile(context); + return File.Exists(dbFile); + } + + private string GetDatabaseFile(DbContext context) { SqliteConnection connection = (SqliteConnection)context.Database.GetDbConnection(); - return File.Exists(connection.DataSource); + return connection.DataSource; } } \ No newline at end of file diff --git a/server/AniStream.Models/Models/ProfileModel.cs b/server/AniStream.Models/Models/ProfileModel.cs index ca63e89..a31f7ee 100644 --- a/server/AniStream.Models/Models/ProfileModel.cs +++ b/server/AniStream.Models/Models/ProfileModel.cs @@ -1,6 +1,8 @@ +using System.ComponentModel.DataAnnotations.Schema; using Microsoft.EntityFrameworkCore; [PrimaryKey(nameof(ProfileId))] +[Table("profile")] public class ProfileModel { public int ProfileId { get; init; } diff --git a/server/AniStream.Models/Utils/IDbMigrator.cs b/server/AniStream.Models/Utils/IDbMigrator.cs index b0eb360..d23b0ef 100644 --- a/server/AniStream.Models/Utils/IDbMigrator.cs +++ b/server/AniStream.Models/Utils/IDbMigrator.cs @@ -29,9 +29,9 @@ protected string ReadMigrationFile(int version) string fullPath = Path.Join(_migrationPath, _driverKeyword, _databaseSchema, $"{version}.sql"); if (!File.Exists(fullPath)) { - throw new ArgumentException($"Migration file not found for version '{version}'"); + throw new ArgumentException($"Migration file not found for version '{version}' ('{fullPath}')"); } return File.ReadAllText(fullPath); } -} \ No newline at end of file +} diff --git a/server/AniStream.Models/Utils/SnakeCaseConvention.cs b/server/AniStream.Models/Utils/SnakeCaseConvention.cs new file mode 100644 index 0000000..74f56ad --- /dev/null +++ b/server/AniStream.Models/Utils/SnakeCaseConvention.cs @@ -0,0 +1,37 @@ +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; + +namespace AniStream.Utils; + +public sealed partial class SnakeCaseConvention : IModelFinalizingConvention +{ + public void ProcessModelFinalizing(IConventionModelBuilder builder, IConventionContext context) + { + foreach (IConventionEntityType entity in builder.Metadata.GetEntityTypes()) + { + entity.SetTableName(entity.GetTableName()?.ToLowerInvariant()); + + foreach (IConventionProperty property in entity.GetProperties()) + { + string columnName = property.GetColumnName(); + property.SetColumnName(ToSnakeCase(columnName)); + } + } + } + + public static string ToSnakeCase(string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + + return SnakeCaseRegex().Replace(input, "$1_$2").ToLowerInvariant(); + } + + [GeneratedRegex("([a-z0-9])([A-Z])")] + private static partial Regex SnakeCaseRegex(); +} \ No newline at end of file From f65ad6faa0463fcc930fd32abfbb3242575fcbd5 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Wed, 22 Apr 2026 09:42:02 +0200 Subject: [PATCH 011/134] Better DB access --- server/AniStream.Models/Models/GenreModel.cs | 14 ++++++ .../AniStream.Models/Models/ProfileModel.cs | 2 + .../Contexts/MetadataDbContext.cs | 13 +++-- .../Contexts/ProfileDbContext.cs | 3 +- .../Contracts/IGenreService.cs | 18 +++++++ .../Contracts/IProviderService.cs | 8 ++++ .../Contracts/IUserService.cs | 2 + .../AniStream.Services/Services/AutoLoader.cs | 39 +++++++++++++-- .../Services/GenreServiceImpl.cs | 48 +++++++++++++++++++ .../Services/ProviderServiceImpl.cs | 29 +++++++++++ .../Services/UserServiceImpl.cs | 1 + .../Controllers/CredentialsController.cs | 4 +- .../AniStream/Controllers/GenreController.cs | 28 +++++++++++ server/AniStream/DTO/GenreModel.cs | 20 ++++++++ .../AniStream/{Models => DTO}/LoginModel.cs | 4 +- .../Middelware/ProviderMiddelware.cs | 26 ++++++++++ server/AniStream/Program.cs | 28 +++++------ .../AniStream/Services/CredentialsService.cs | 1 + 18 files changed, 259 insertions(+), 29 deletions(-) create mode 100644 server/AniStream.Models/Models/GenreModel.cs rename server/{AniStream.Models => AniStream.Services}/Contexts/MetadataDbContext.cs (69%) rename server/{AniStream.Models => AniStream.Services}/Contexts/ProfileDbContext.cs (95%) create mode 100644 server/AniStream.Services/Contracts/IGenreService.cs create mode 100644 server/AniStream.Services/Contracts/IProviderService.cs create mode 100644 server/AniStream.Services/Services/GenreServiceImpl.cs create mode 100644 server/AniStream.Services/Services/ProviderServiceImpl.cs create mode 100644 server/AniStream/Controllers/GenreController.cs create mode 100644 server/AniStream/DTO/GenreModel.cs rename server/AniStream/{Models => DTO}/LoginModel.cs (63%) create mode 100644 server/AniStream/Middelware/ProviderMiddelware.cs diff --git a/server/AniStream.Models/Models/GenreModel.cs b/server/AniStream.Models/Models/GenreModel.cs new file mode 100644 index 0000000..29ef94a --- /dev/null +++ b/server/AniStream.Models/Models/GenreModel.cs @@ -0,0 +1,14 @@ + +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("genre")] +[PrimaryKey(nameof(GenreId))] +public class GenreModel +{ + public int GenreId {get; set; } + + public string Key { get; set; } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/ProfileModel.cs b/server/AniStream.Models/Models/ProfileModel.cs index a31f7ee..5d67dde 100644 --- a/server/AniStream.Models/Models/ProfileModel.cs +++ b/server/AniStream.Models/Models/ProfileModel.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations.Schema; using Microsoft.EntityFrameworkCore; +namespace AniStream.Models; + [PrimaryKey(nameof(ProfileId))] [Table("profile")] public class ProfileModel diff --git a/server/AniStream.Models/Contexts/MetadataDbContext.cs b/server/AniStream.Services/Contexts/MetadataDbContext.cs similarity index 69% rename from server/AniStream.Models/Contexts/MetadataDbContext.cs rename to server/AniStream.Services/Contexts/MetadataDbContext.cs index 501be33..c999212 100644 --- a/server/AniStream.Models/Contexts/MetadataDbContext.cs +++ b/server/AniStream.Services/Contexts/MetadataDbContext.cs @@ -1,4 +1,6 @@ -using AniStream.Utils; +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Utils; using Microsoft.EntityFrameworkCore; namespace AniStream.Contexts; @@ -13,19 +15,24 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura public MetadataDbContext(DbContextOptions options) : base(options) { } + + public DbSet Genres { get; set; } } public sealed class MetadataDbContextFactory : DbContextFactory { private readonly string _connectionString; + private readonly IProviderService _providerService; - public MetadataDbContextFactory(string dbType, string migrationFolder, string connectionString) : base(dbType, migrationFolder, "metadata") + public MetadataDbContextFactory(string dbType, string migrationFolder, string connectionString, IProviderService providerService) : base(dbType, migrationFolder, "metadata") { _connectionString = connectionString; + _providerService = providerService; } - public async Task GetContext(string providerName) + public async Task GetContext() { + string providerName = _providerService.GetActiveProvider(); string actualConnString = String.Format(_connectionString, providerName); DbContextOptionsBuilder builder = new DbContextOptionsBuilder(); diff --git a/server/AniStream.Models/Contexts/ProfileDbContext.cs b/server/AniStream.Services/Contexts/ProfileDbContext.cs similarity index 95% rename from server/AniStream.Models/Contexts/ProfileDbContext.cs rename to server/AniStream.Services/Contexts/ProfileDbContext.cs index 89f04da..3bfd069 100644 --- a/server/AniStream.Models/Contexts/ProfileDbContext.cs +++ b/server/AniStream.Services/Contexts/ProfileDbContext.cs @@ -1,4 +1,5 @@ -using AniStream.Utils; +using AniStream.Models; +using AniStream.Utils; using Microsoft.EntityFrameworkCore; namespace AniStream.Contexts; diff --git a/server/AniStream.Services/Contracts/IGenreService.cs b/server/AniStream.Services/Contracts/IGenreService.cs new file mode 100644 index 0000000..8c35f95 --- /dev/null +++ b/server/AniStream.Services/Contracts/IGenreService.cs @@ -0,0 +1,18 @@ +using AniStream.Models; + +namespace AniStream.Contracts; + +public interface IGenreService +{ + public Task GetGenreByKey(string key); + + public Task CreateGenre(string key); + + public Task CreateGenreToSeries(int genreId, int seriesId, bool mainGenre); + + public Task GetGenres(); + + public Task GetMainGenreOfSeries(int seriesId); + + public Task GetNonMainGenresOfSeries(int seriesId); +} \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/IProviderService.cs b/server/AniStream.Services/Contracts/IProviderService.cs new file mode 100644 index 0000000..93987cf --- /dev/null +++ b/server/AniStream.Services/Contracts/IProviderService.cs @@ -0,0 +1,8 @@ +namespace AniStream.Contracts; + +public interface IProviderService +{ + public string GetActiveProvider(); + + public void SetActiveProvider(string providerName); +} \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/IUserService.cs b/server/AniStream.Services/Contracts/IUserService.cs index ab688db..2d486b1 100644 --- a/server/AniStream.Services/Contracts/IUserService.cs +++ b/server/AniStream.Services/Contracts/IUserService.cs @@ -1,3 +1,5 @@ +using AniStream.Models; + namespace AniStream.Contracts; public interface IUserService diff --git a/server/AniStream.Services/Services/AutoLoader.cs b/server/AniStream.Services/Services/AutoLoader.cs index fb704c9..be9e6e4 100644 --- a/server/AniStream.Services/Services/AutoLoader.cs +++ b/server/AniStream.Services/Services/AutoLoader.cs @@ -1,12 +1,45 @@ -using AniStream.Contracts; +using AniStream.Contexts; +using AniStream.Contracts; using Microsoft.Extensions.DependencyInjection; namespace AniStream.Services; public sealed class AutoLoader { - public static void LoadServices(IServiceCollection services) + public static void LoadServices(IServiceCollection services, Options options) { - services.AddSingleton(); + services.AddScoped(sp => + new ProfileDbContextFactory( + options.DatabaseDriver, + options.MigrationPath, + options.DatabaseProfileConnectionString + ) + ); + services.AddScoped(sp => { + IProviderService providerService = sp.GetRequiredService(); + + return new MetadataDbContextFactory( + options.DatabaseDriver, + options.MigrationPath, + options.DatabaseMetadataConnectionString, + providerService + ); + }); + + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + } + + public struct Options + { + public string DatabaseDriver; + + public string MigrationPath; + + public string DatabaseProfileConnectionString; + + public string DatabaseMetadataConnectionString; } } \ No newline at end of file diff --git a/server/AniStream.Services/Services/GenreServiceImpl.cs b/server/AniStream.Services/Services/GenreServiceImpl.cs new file mode 100644 index 0000000..9594dcc --- /dev/null +++ b/server/AniStream.Services/Services/GenreServiceImpl.cs @@ -0,0 +1,48 @@ +using System.Threading.Tasks; +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Models; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Services; + +public sealed class GenreServiceImpl : IGenreService +{ + private readonly MetadataDbContextFactory _dbFactory; + + public GenreServiceImpl(MetadataDbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public Task CreateGenre(string key) + { + throw new NotImplementedException(); + } + + public Task CreateGenreToSeries(int genreId, int seriesId, bool mainGenre) + { + throw new NotImplementedException(); + } + + public Task GetGenreByKey(string key) + { + throw new NotImplementedException(); + } + + public async Task GetGenres() + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + return await db.Genres.ToArrayAsync(); + } + + public Task GetMainGenreOfSeries(int seriesId) + { + throw new NotImplementedException(); + } + + public Task GetNonMainGenresOfSeries(int seriesId) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/ProviderServiceImpl.cs b/server/AniStream.Services/Services/ProviderServiceImpl.cs new file mode 100644 index 0000000..13a3cef --- /dev/null +++ b/server/AniStream.Services/Services/ProviderServiceImpl.cs @@ -0,0 +1,29 @@ +using AniStream.Contracts; + +namespace AniStream.Services; + +public sealed class ProviderServiceImpl : IProviderService +{ + public string? _providerName = null; + + public string GetActiveProvider() + { + if (_providerName is null) + { + throw new Exception("Active provider is not set. Did you forget to set it?"); + } + + return _providerName; + } + + public void SetActiveProvider(string providerName) + { + // TODO maybe resolve dynamic + if (providerName != "aniworld" && providerName != "sto") + { + throw new ArgumentException($"Invalid provider name '{providerName}'", nameof(providerName)); + } + + _providerName = providerName; + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/UserServiceImpl.cs b/server/AniStream.Services/Services/UserServiceImpl.cs index c931876..d67b0b6 100644 --- a/server/AniStream.Services/Services/UserServiceImpl.cs +++ b/server/AniStream.Services/Services/UserServiceImpl.cs @@ -1,5 +1,6 @@ using AniStream.Contexts; using AniStream.Contracts; +using AniStream.Models; using Microsoft.EntityFrameworkCore; namespace AniStream.Services; diff --git a/server/AniStream/Controllers/CredentialsController.cs b/server/AniStream/Controllers/CredentialsController.cs index 394171b..a68b4d0 100644 --- a/server/AniStream/Controllers/CredentialsController.cs +++ b/server/AniStream/Controllers/CredentialsController.cs @@ -1,5 +1,5 @@ using System.Security.Claims; -using AniStream.API.Models; +using AniStream.API.DTO; using AniStream.API.Utils; using AniStream.Contracts; using Microsoft.AspNetCore.Authentication; @@ -11,7 +11,7 @@ namespace AniStream.API.Controllers; [Route("api/credentials")] [ApiController] -public class CredentialsController : ApiControllerBase +public sealed class CredentialsController : ApiControllerBase { public const string LOGIN_ROUTE = "/api/credentials/login"; public const string LOGOUT_ROUTE = "/api/credentials/logout"; diff --git a/server/AniStream/Controllers/GenreController.cs b/server/AniStream/Controllers/GenreController.cs new file mode 100644 index 0000000..0e7420f --- /dev/null +++ b/server/AniStream/Controllers/GenreController.cs @@ -0,0 +1,28 @@ +using AniStream.API.DTO; +using AniStream.API.Utils; +using AniStream.Contracts; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AniStream.API.Controllers; + +[Route("api/{provider}/genres")] +[ApiController] +[Authorize] +public sealed class GenreController : ApiControllerBase +{ + private readonly IGenreService _genreService; + + public GenreController(IGenreService genreService) + { + _genreService = genreService; + } + + [HttpGet] + public async Task GetGenres() + { + Models.GenreModel[] genres = await _genreService.GetGenres(); + + return genres.Select(genre => genre.ToDTO()).ToArray(); + } +} \ No newline at end of file diff --git a/server/AniStream/DTO/GenreModel.cs b/server/AniStream/DTO/GenreModel.cs new file mode 100644 index 0000000..dc5f36d --- /dev/null +++ b/server/AniStream/DTO/GenreModel.cs @@ -0,0 +1,20 @@ +namespace AniStream.API.DTO; + +public class GenreModel +{ + public required int GenreId { get; set; } + + public required string Key { get; set; } +} + +internal static class GenreModelHelper +{ + public static GenreModel ToDTO(this Models.GenreModel model) + { + return new GenreModel + { + GenreId = model.GenreId, + Key = model.Key + }; + } +} \ No newline at end of file diff --git a/server/AniStream/Models/LoginModel.cs b/server/AniStream/DTO/LoginModel.cs similarity index 63% rename from server/AniStream/Models/LoginModel.cs rename to server/AniStream/DTO/LoginModel.cs index af94fac..9b9b089 100644 --- a/server/AniStream/Models/LoginModel.cs +++ b/server/AniStream/DTO/LoginModel.cs @@ -1,6 +1,4 @@ -using System.ComponentModel.DataAnnotations; - -namespace AniStream.API.Models; +namespace AniStream.API.DTO; public sealed class LoginModel { diff --git a/server/AniStream/Middelware/ProviderMiddelware.cs b/server/AniStream/Middelware/ProviderMiddelware.cs new file mode 100644 index 0000000..05d9324 --- /dev/null +++ b/server/AniStream/Middelware/ProviderMiddelware.cs @@ -0,0 +1,26 @@ +using AniStream.Contracts; + +namespace AniStream.API.Middelware; + +public class ProviderMiddelware +{ + private readonly RequestDelegate _next; + + public ProviderMiddelware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, IProviderService providerService) + { + if (context.Request.RouteValues.TryGetValue("provider", out object? value)) + { + if (value is string provider) + { + providerService.SetActiveProvider(provider); + } + } + + await _next(context); + } +} \ No newline at end of file diff --git a/server/AniStream/Program.cs b/server/AniStream/Program.cs index a2b4e8f..0454976 100644 --- a/server/AniStream/Program.cs +++ b/server/AniStream/Program.cs @@ -4,10 +4,10 @@ using AniStream.API.Controllers; using AniStream.Contracts; using AniStream.API.Serivces; -using AniStream.Contexts; using AniStream.Services; using Microsoft.OpenApi.Models; using Microsoft.AspNetCore.Mvc; +using AniStream.API.Middelware; namespace AniStream.API; @@ -66,6 +66,8 @@ public static void Main(string[] args) WebApplication app = builder.Build(); + app.UseMiddleware(); + if (app.Environment.IsProduction()) { app.UseHttpsRedirection(); @@ -83,24 +85,16 @@ public static void Main(string[] args) private static void SetupDependencyInjection(WebApplicationBuilder builder) { - // DB Contexts - builder.Services.AddSingleton( - new ProfileDbContextFactory( - AppConfig.CurrentConfig.DatabaseDriver, - AppConfig.CurrentConfig.MigrationPath, - AppConfig.CurrentConfig.DatabaseProfileConnectionString) - ); - builder.Services.AddSingleton( - new MetadataDbContextFactory( - AppConfig.CurrentConfig.DatabaseDriver, - AppConfig.CurrentConfig.MigrationPath, - AppConfig.CurrentConfig.DatabaseMetadataConnectionString) - ); - // Proprietary services - builder.Services.AddSingleton(); + builder.Services.AddScoped(); // BL Layer - AutoLoader.LoadServices(builder.Services); + AutoLoader.LoadServices(builder.Services, new AutoLoader.Options + { + DatabaseDriver = AppConfig.CurrentConfig.DatabaseDriver, + MigrationPath = AppConfig.CurrentConfig.MigrationPath, + DatabaseMetadataConnectionString = AppConfig.CurrentConfig.DatabaseMetadataConnectionString, + DatabaseProfileConnectionString = AppConfig.CurrentConfig.DatabaseProfileConnectionString + }); } } \ No newline at end of file diff --git a/server/AniStream/Services/CredentialsService.cs b/server/AniStream/Services/CredentialsService.cs index fe46ed5..6252f6d 100644 --- a/server/AniStream/Services/CredentialsService.cs +++ b/server/AniStream/Services/CredentialsService.cs @@ -1,3 +1,4 @@ +using AniStream.Models; using AniStream.Contracts; namespace AniStream.API.Serivces; From 2413af85c7f1a3ed6aea17f5a4af4488c933324c Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Wed, 22 Apr 2026 10:28:37 +0200 Subject: [PATCH 012/134] Implement Genre Service --- migration/sqlite/metadata/1.sql | 2 +- server/AniStream.Models/Models/GenreModel.cs | 15 ++++++- .../Models/GenreToSeriesModel.cs | 22 ++++++++++ .../Contexts/MetadataDbContext.cs | 2 + .../Contracts/IGenreService.cs | 4 +- .../Services/GenreServiceImpl.cs | 44 ++++++++++++++----- .../AniStream/Controllers/GenreController.cs | 21 +++++++++ server/AniStream/Utils/ApiControllerBase.cs | 13 +++++- 8 files changed, 105 insertions(+), 18 deletions(-) create mode 100644 server/AniStream.Models/Models/GenreToSeriesModel.cs diff --git a/migration/sqlite/metadata/1.sql b/migration/sqlite/metadata/1.sql index d708176..f544742 100644 --- a/migration/sqlite/metadata/1.sql +++ b/migration/sqlite/metadata/1.sql @@ -17,10 +17,10 @@ CREATE TABLE genre CREATE TABLE genre_to_series ( - genre_to_series_id INTEGER PRIMARY KEY AUTOINCREMENT, genre_id INTEGER NOT NULL, series_id INTEGER NOT NULL, main_genre BOOLEAN NOT NULL, + PRIMARY KEY (genre_id, series_id), FOREIGN KEY (genre_id) REFERENCES genre (genre_id) ON DELETE RESTRICT, FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT ); diff --git a/server/AniStream.Models/Models/GenreModel.cs b/server/AniStream.Models/Models/GenreModel.cs index 29ef94a..d9690a4 100644 --- a/server/AniStream.Models/Models/GenreModel.cs +++ b/server/AniStream.Models/Models/GenreModel.cs @@ -8,7 +8,18 @@ namespace AniStream.Models; [PrimaryKey(nameof(GenreId))] public class GenreModel { - public int GenreId {get; set; } + public int GenreId { get; set; } - public string Key { get; set; } + public string Key { get; set; } + + public GenreModel(string key) : this(0, key) + { + + } + + public GenreModel(int genreId, string key) + { + GenreId = genreId; + Key = key; + } } \ No newline at end of file diff --git a/server/AniStream.Models/Models/GenreToSeriesModel.cs b/server/AniStream.Models/Models/GenreToSeriesModel.cs new file mode 100644 index 0000000..fe25939 --- /dev/null +++ b/server/AniStream.Models/Models/GenreToSeriesModel.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("genre_to_series")] +[PrimaryKey(nameof(GenreId), nameof(SeriesId))] +public class GenreToSeries +{ + public int GenreId { get; set; } + + public int SeriesId { get; set; } + + public bool MainGenre { get; set; } + + public GenreToSeries(int genreId, int seriesId, bool mainGenre) + { + GenreId = genreId; + SeriesId = seriesId; + MainGenre = mainGenre; + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Contexts/MetadataDbContext.cs b/server/AniStream.Services/Contexts/MetadataDbContext.cs index c999212..fa497d1 100644 --- a/server/AniStream.Services/Contexts/MetadataDbContext.cs +++ b/server/AniStream.Services/Contexts/MetadataDbContext.cs @@ -17,6 +17,8 @@ public MetadataDbContext(DbContextOptions options) : base(opt } public DbSet Genres { get; set; } + + public DbSet GenresToSeries { get; set; } } public sealed class MetadataDbContextFactory : DbContextFactory diff --git a/server/AniStream.Services/Contracts/IGenreService.cs b/server/AniStream.Services/Contracts/IGenreService.cs index 8c35f95..1f94ff0 100644 --- a/server/AniStream.Services/Contracts/IGenreService.cs +++ b/server/AniStream.Services/Contracts/IGenreService.cs @@ -4,14 +4,14 @@ namespace AniStream.Contracts; public interface IGenreService { - public Task GetGenreByKey(string key); - public Task CreateGenre(string key); public Task CreateGenreToSeries(int genreId, int seriesId, bool mainGenre); public Task GetGenres(); + public Task GetGenreByKey(string key); + public Task GetMainGenreOfSeries(int seriesId); public Task GetNonMainGenresOfSeries(int seriesId); diff --git a/server/AniStream.Services/Services/GenreServiceImpl.cs b/server/AniStream.Services/Services/GenreServiceImpl.cs index 9594dcc..9852f3c 100644 --- a/server/AniStream.Services/Services/GenreServiceImpl.cs +++ b/server/AniStream.Services/Services/GenreServiceImpl.cs @@ -15,19 +15,25 @@ public GenreServiceImpl(MetadataDbContextFactory dbFactory) _dbFactory = dbFactory; } - public Task CreateGenre(string key) + public async Task CreateGenre(string key) { - throw new NotImplementedException(); - } + await using MetadataDbContext db = await _dbFactory.GetContext(); + GenreModel model = new GenreModel(key); - public Task CreateGenreToSeries(int genreId, int seriesId, bool mainGenre) - { - throw new NotImplementedException(); + db.Genres.Add(model); + await db.SaveChangesAsync(); + + return model; } - public Task GetGenreByKey(string key) + public async Task CreateGenreToSeries(int genreId, int seriesId, bool mainGenre) { - throw new NotImplementedException(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + GenreToSeries genreToSeries = new GenreToSeries(genreId, seriesId, mainGenre); + db.GenresToSeries.Add(genreToSeries); + + await db.SaveChangesAsync(); } public async Task GetGenres() @@ -36,13 +42,27 @@ public async Task GetGenres() return await db.Genres.ToArrayAsync(); } - public Task GetMainGenreOfSeries(int seriesId) + public async Task GetGenreByKey(string key) { - throw new NotImplementedException(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from genre in db.Genres where genre.Key == key select genre; + return query.FirstOrDefault(); } - public Task GetNonMainGenresOfSeries(int seriesId) + public async Task GetMainGenreOfSeries(int seriesId) { - throw new NotImplementedException(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from gs in db.GenresToSeries join g in db.Genres on gs.GenreId equals g.GenreId where gs.SeriesId == seriesId && gs.MainGenre select g; + return query.FirstOrDefault(); + } + + public async Task GetNonMainGenresOfSeries(int seriesId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from gs in db.GenresToSeries join g in db.Genres on gs.GenreId equals g.GenreId where gs.SeriesId == seriesId && !gs.MainGenre select g; + return await query.ToArrayAsync(); } } \ No newline at end of file diff --git a/server/AniStream/Controllers/GenreController.cs b/server/AniStream/Controllers/GenreController.cs index 0e7420f..e282c42 100644 --- a/server/AniStream/Controllers/GenreController.cs +++ b/server/AniStream/Controllers/GenreController.cs @@ -3,6 +3,7 @@ using AniStream.Contracts; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore.Metadata.Internal; namespace AniStream.API.Controllers; @@ -25,4 +26,24 @@ public async Task GetGenres() return genres.Select(genre => genre.ToDTO()).ToArray(); } + + [HttpGet("{seriesId}")] + public async Task GetGenresOfSeries(int seriesId) + { + Models.GenreModel[] genres = await _genreService.GetNonMainGenresOfSeries(seriesId); + + return genres.Select(genre => genre.ToDTO()).ToArray(); + } + + [HttpGet("{seriesId}/main")] + public async Task> GetMainGenreOfSeries(int seriesId) + { + Models.GenreModel? genre = await _genreService.GetMainGenreOfSeries(seriesId); + if (genre is null) + { + return NotFound("Series dont have a main genre"); + } + + return Ok(genre); + } } \ No newline at end of file diff --git a/server/AniStream/Utils/ApiControllerBase.cs b/server/AniStream/Utils/ApiControllerBase.cs index f81771c..85dc753 100644 --- a/server/AniStream/Utils/ApiControllerBase.cs +++ b/server/AniStream/Utils/ApiControllerBase.cs @@ -5,7 +5,7 @@ namespace AniStream.API.Utils; public abstract class ApiControllerBase : ControllerBase { - protected IActionResult Unauthorized(string message) + protected ObjectResult Unauthorized(string message) { return Problem( title: "Unauthorized", @@ -13,4 +13,15 @@ protected IActionResult Unauthorized(string message) statusCode: StatusCodes.Status401Unauthorized ); } + + protected ObjectResult NotFound(string message) + { + return Problem( + title: "Not found", + detail: message, + statusCode: StatusCodes.Status404NotFound + ); + } + + } \ No newline at end of file From 7a440a4f1bb6e324f4cb22698264d11a7a5f4ce4 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Wed, 22 Apr 2026 10:36:54 +0200 Subject: [PATCH 013/134] Update Model --- .../AniStream.Models/Models/ProfileModel.cs | 70 ++++++++++++++++--- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/server/AniStream.Models/Models/ProfileModel.cs b/server/AniStream.Models/Models/ProfileModel.cs index 5d67dde..35cb43c 100644 --- a/server/AniStream.Models/Models/ProfileModel.cs +++ b/server/AniStream.Models/Models/ProfileModel.cs @@ -7,23 +7,73 @@ namespace AniStream.Models; [Table("profile")] public class ProfileModel { - public int ProfileId { get; init; } + public int ProfileId { get; set; } - public string Uuid { get; init; } + public string Uuid { get; set; } - public string Name { get; init; } + public string Name { get; set; } - public string BackgroundColor { get; init; } + public string BackgroundColor { get; set; } - public string Eye { get; init; } + public string Eye { get; set; } - public string Mouth { get; init; } + public string Mouth { get; set; } - public string Theme { get; init; } + public string Theme { get; set; } - public string Lang { get; init; } + public string Lang { get; set; } - public bool TosAccepted { get; init; } + public bool TosAccepted { get; set; } - public bool SyncCatalog { get; init; } + public bool SyncCatalog { get; set; } + + public ProfileModel( + int profileId, + string uuid, + string name, + string backgroundColor, + string eye, + string mouth, + string theme, + string lang, + bool tosAccepted, + bool syncCatalog + ) + { + ProfileId = profileId; + Uuid = uuid; + Name = name; + BackgroundColor = backgroundColor; + Eye = eye; + Mouth = mouth; + Theme = theme; + Lang = lang; + TosAccepted = tosAccepted; + SyncCatalog = syncCatalog; + } + + public ProfileModel( + string uuid, + string name, + string backgroundColor, + string eye, + string mouth, + string theme, + string lang, + bool tosAccepted, + bool syncCatalog + ) : this( + 0, + uuid, + name, + backgroundColor, + eye, + mouth, + theme, + lang, + tosAccepted, + syncCatalog + ) + { + } } \ No newline at end of file From e40c161271c9b42c1f2aeebfa414fc6f0eb6ff57 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Wed, 22 Apr 2026 10:59:55 +0200 Subject: [PATCH 014/134] Add Models --- .../AniStream.Models/Models/EpisodeModel.cs | 49 +++++++++++++++++++ server/AniStream.Models/Models/GenreModel.cs | 2 +- .../Models/GenreToSeriesModel.cs | 2 +- .../AniStream.Models/Models/ProfileModel.cs | 2 +- server/AniStream.Models/Models/SeasonModel.cs | 26 ++++++++++ server/AniStream.Models/Models/SeriesModel.cs | 49 +++++++++++++++++++ 6 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 server/AniStream.Models/Models/EpisodeModel.cs create mode 100644 server/AniStream.Models/Models/SeasonModel.cs create mode 100644 server/AniStream.Models/Models/SeriesModel.cs diff --git a/server/AniStream.Models/Models/EpisodeModel.cs b/server/AniStream.Models/Models/EpisodeModel.cs new file mode 100644 index 0000000..dbd4f4c --- /dev/null +++ b/server/AniStream.Models/Models/EpisodeModel.cs @@ -0,0 +1,49 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("episode")] +[PrimaryKey(nameof(EpisodeId))] +public sealed class EpisodeModel +{ + public int EpisodeId { get; set; } + + public int EpisodeNumber { get; set; } + + public string GermanTitle { get; set; } + + public string EnglishTitle { get; set; } + + public string Description { get; set; } + + public EpisodeModel( + int episodeId, + int episodeNumber, + string germanTitle, + string englishTitle, + string description + ) + { + EpisodeId = episodeId; + EpisodeNumber = episodeNumber; + GermanTitle = germanTitle; + EnglishTitle = englishTitle; + Description = description; + } + + public EpisodeModel( + int episodeNumber, + string germanTitle, + string englishTitle, + string description + ) : this( + 0, + episodeNumber, + germanTitle, + englishTitle, + description + ) + { + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/GenreModel.cs b/server/AniStream.Models/Models/GenreModel.cs index d9690a4..6fc6ae3 100644 --- a/server/AniStream.Models/Models/GenreModel.cs +++ b/server/AniStream.Models/Models/GenreModel.cs @@ -6,7 +6,7 @@ namespace AniStream.Models; [Table("genre")] [PrimaryKey(nameof(GenreId))] -public class GenreModel +public sealed class GenreModel { public int GenreId { get; set; } diff --git a/server/AniStream.Models/Models/GenreToSeriesModel.cs b/server/AniStream.Models/Models/GenreToSeriesModel.cs index fe25939..b878e7a 100644 --- a/server/AniStream.Models/Models/GenreToSeriesModel.cs +++ b/server/AniStream.Models/Models/GenreToSeriesModel.cs @@ -5,7 +5,7 @@ namespace AniStream.Models; [Table("genre_to_series")] [PrimaryKey(nameof(GenreId), nameof(SeriesId))] -public class GenreToSeries +public sealed class GenreToSeries { public int GenreId { get; set; } diff --git a/server/AniStream.Models/Models/ProfileModel.cs b/server/AniStream.Models/Models/ProfileModel.cs index 35cb43c..2582404 100644 --- a/server/AniStream.Models/Models/ProfileModel.cs +++ b/server/AniStream.Models/Models/ProfileModel.cs @@ -5,7 +5,7 @@ namespace AniStream.Models; [PrimaryKey(nameof(ProfileId))] [Table("profile")] -public class ProfileModel +public sealed class ProfileModel { public int ProfileId { get; set; } diff --git a/server/AniStream.Models/Models/SeasonModel.cs b/server/AniStream.Models/Models/SeasonModel.cs new file mode 100644 index 0000000..dc74f4d --- /dev/null +++ b/server/AniStream.Models/Models/SeasonModel.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("season")] +[PrimaryKey(nameof(SeasonId))] +public sealed class SeasonModel +{ + public int SeasonId { get; set; } + + public int SeriesId { get; set; } + + public int SeasonNumber { get; set; } + + public SeasonModel(int seasonId, int seriesId, int seasonNumber) + { + SeasonId = seasonId; + SeriesId = seriesId; + SeasonNumber = seasonNumber; + } + + public SeasonModel(int seriesId, int seasonNumber) : this(0, seriesId, seasonNumber) + { + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/SeriesModel.cs b/server/AniStream.Models/Models/SeriesModel.cs new file mode 100644 index 0000000..d3712eb --- /dev/null +++ b/server/AniStream.Models/Models/SeriesModel.cs @@ -0,0 +1,49 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("series")] +[PrimaryKey(nameof(SeriesId))] +public sealed class SeriesModel +{ + public int SeriesId { get; set; } + + public string Guid { get; set; } + + public string Title { get; set; } + + public string Description { get; set; } + + public string? PreviewImage { get; set; } + + public SeriesModel( + int seriesId, + string guid, + string title, + string description, + string previewImage + ) + { + SeriesId = seriesId; + Guid = guid; + Title = title; + Description = description; + PreviewImage = previewImage; + } + + public SeriesModel( + string guid, + string title, + string description, + string previewImage + ) : this ( + 0, + guid, + title, + description, + previewImage + ) + { + } +} \ No newline at end of file From 68ea0d4e9143b082cf4ca46de08035f0f7f11ebb Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Wed, 22 Apr 2026 11:11:56 +0200 Subject: [PATCH 015/134] Make prop nullable --- server/AniStream.Models/Models/SeriesModel.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/AniStream.Models/Models/SeriesModel.cs b/server/AniStream.Models/Models/SeriesModel.cs index d3712eb..db16890 100644 --- a/server/AniStream.Models/Models/SeriesModel.cs +++ b/server/AniStream.Models/Models/SeriesModel.cs @@ -22,7 +22,7 @@ public SeriesModel( string guid, string title, string description, - string previewImage + string? previewImage ) { SeriesId = seriesId; @@ -36,7 +36,7 @@ public SeriesModel( string guid, string title, string description, - string previewImage + string? previewImage ) : this ( 0, guid, From a369baa73f2933c67e47c63758cd7ccc70b63313 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Wed, 22 Apr 2026 11:28:41 +0200 Subject: [PATCH 016/134] Add models as DbSet --- server/AniStream.Services/Contexts/MetadataDbContext.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/AniStream.Services/Contexts/MetadataDbContext.cs b/server/AniStream.Services/Contexts/MetadataDbContext.cs index fa497d1..7b30a61 100644 --- a/server/AniStream.Services/Contexts/MetadataDbContext.cs +++ b/server/AniStream.Services/Contexts/MetadataDbContext.cs @@ -19,6 +19,12 @@ public MetadataDbContext(DbContextOptions options) : base(opt public DbSet Genres { get; set; } public DbSet GenresToSeries { get; set; } + + public DbSet Series { get; set; } + + public DbSet Seasons { get; set; } + + public DbSet Episodes { get; set; } } public sealed class MetadataDbContextFactory : DbContextFactory From 565a31e1609f41db756cdcfc9c67c9f2ffa3686d Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Wed, 22 Apr 2026 11:28:53 +0200 Subject: [PATCH 017/134] Add Series Service --- .../Contracts/ISeriesService.cs | 32 +++++++ .../Services/SeriesServiceImpl.cs | 94 +++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 server/AniStream.Services/Contracts/ISeriesService.cs create mode 100644 server/AniStream.Services/Services/SeriesServiceImpl.cs diff --git a/server/AniStream.Services/Contracts/ISeriesService.cs b/server/AniStream.Services/Contracts/ISeriesService.cs new file mode 100644 index 0000000..6f8061b --- /dev/null +++ b/server/AniStream.Services/Contracts/ISeriesService.cs @@ -0,0 +1,32 @@ +using AniStream.Models; + +namespace AniStream.Contracts; + +public interface ISeriesService +{ + public Task GetSeries(int seriesId); + + public Task GetSeries(string guid); + + public Task CreateSeries( + string guid, + string title, + string description, + string? previewImage + ); + + public Task GetSeriesChunk(int offset, int limit); + + public Task GetSeriesChunk(int offset, int limit, string searchText); + + public Task GetSeriesChunk( + int offset, + int limit, + string searchText, + int[] genreIds + ); + + public Task GetStartedSeries(); + + public Task GetSeriesByIds(int[] seriesIds); +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/SeriesServiceImpl.cs b/server/AniStream.Services/Services/SeriesServiceImpl.cs new file mode 100644 index 0000000..85db9fb --- /dev/null +++ b/server/AniStream.Services/Services/SeriesServiceImpl.cs @@ -0,0 +1,94 @@ +using System.ComponentModel.DataAnnotations; +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Models; + +namespace AniStream.Services; + +public sealed class SeriesSerivceImpl : ISeriesService +{ + private MetadataDbContextFactory _dbFactory; + + public SeriesSerivceImpl(MetadataDbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public async Task CreateSeries(string guid, string title, string description, string? previewImage) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + SeriesModel series = new SeriesModel(guid, title, description, previewImage); + + db.Series.Add(series); + await db.SaveChangesAsync(); + + return series; + } + + public async Task GetSeries(int seriesId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from series in db.Series where series.SeriesId == seriesId select series; + return query.FirstOrDefault(); + } + + public async Task GetSeries(string guid) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from series in db.Series where series.Guid == guid select series; + return query.FirstOrDefault(); + } + + public async Task GetSeriesByIds(int[] seriesIds) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from series in db.Series where seriesIds.Contains(series.SeriesId) select series; + return query.ToArray(); + } + + public async Task GetSeriesChunk(int offset, int limit) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from series in db.Series orderby series.Title select series; + return query.Skip(offset).Take(limit).ToArray(); + } + + public async Task GetSeriesChunk( + int offset, + int limit, + string searchText + ) + { + searchText = searchText.ToLower(); + + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from series in db.Series orderby series.Title where series.Title.ToLower().Contains(searchText) select series; + return query.Skip(offset).Take(limit).ToArray(); + } + + public async Task GetSeriesChunk( + int offset, + int limit, + string searchText, + int[] genreIds + ) + { + searchText = searchText.ToLower(); + + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from series in db.Series join genre in db.GenresToSeries on series.SeriesId equals genre.GenreId orderby series.Title where series.Title.ToLower().Contains(searchText) && genreIds.Contains(genre.GenreId) select series; + return query.Skip(offset).Take(limit).ToArray(); + } + + public Task GetStartedSeries() + { + throw new NotImplementedException(); + } +} \ No newline at end of file From 5bbc92e60c78f23b374a379f822e547c49aaef5e Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Wed, 22 Apr 2026 11:38:06 +0200 Subject: [PATCH 018/134] Add service to Loader --- server/AniStream.Services/Services/AutoLoader.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/server/AniStream.Services/Services/AutoLoader.cs b/server/AniStream.Services/Services/AutoLoader.cs index be9e6e4..75bf9d4 100644 --- a/server/AniStream.Services/Services/AutoLoader.cs +++ b/server/AniStream.Services/Services/AutoLoader.cs @@ -30,6 +30,7 @@ public static void LoadServices(IServiceCollection services, Options options) services.AddScoped(); services.AddScoped(); + services.AddScoped(); } public struct Options From 3ec5f5e3f8a9ad8616333cf37bbfa8b0a31d3561 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Wed, 22 Apr 2026 11:38:48 +0200 Subject: [PATCH 019/134] Make class sealed --- server/AniStream/DTO/GenreModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/AniStream/DTO/GenreModel.cs b/server/AniStream/DTO/GenreModel.cs index dc5f36d..9d1757a 100644 --- a/server/AniStream/DTO/GenreModel.cs +++ b/server/AniStream/DTO/GenreModel.cs @@ -1,6 +1,6 @@ namespace AniStream.API.DTO; -public class GenreModel +public sealed class GenreModel { public required int GenreId { get; set; } From a06bc2e1702c2339a88dd9e74c99b12c287c0181 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Wed, 22 Apr 2026 12:07:38 +0200 Subject: [PATCH 020/134] Use define single GetChunk method --- .../Contracts/ISeriesService.cs | 8 +--- .../Services/SeriesServiceImpl.cs | 43 ++++++++----------- 2 files changed, 20 insertions(+), 31 deletions(-) diff --git a/server/AniStream.Services/Contracts/ISeriesService.cs b/server/AniStream.Services/Contracts/ISeriesService.cs index 6f8061b..94a85ed 100644 --- a/server/AniStream.Services/Contracts/ISeriesService.cs +++ b/server/AniStream.Services/Contracts/ISeriesService.cs @@ -15,15 +15,11 @@ public Task CreateSeries( string? previewImage ); - public Task GetSeriesChunk(int offset, int limit); - - public Task GetSeriesChunk(int offset, int limit, string searchText); - public Task GetSeriesChunk( int offset, int limit, - string searchText, - int[] genreIds + string? searchText, + int[]? genreIds ); public Task GetStartedSeries(); diff --git a/server/AniStream.Services/Services/SeriesServiceImpl.cs b/server/AniStream.Services/Services/SeriesServiceImpl.cs index 85db9fb..53f1adf 100644 --- a/server/AniStream.Services/Services/SeriesServiceImpl.cs +++ b/server/AniStream.Services/Services/SeriesServiceImpl.cs @@ -2,6 +2,8 @@ using AniStream.Contexts; using AniStream.Contracts; using AniStream.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Internal; namespace AniStream.Services; @@ -50,41 +52,32 @@ public async Task GetSeriesByIds(int[] seriesIds) return query.ToArray(); } - public async Task GetSeriesChunk(int offset, int limit) - { - await using MetadataDbContext db = await _dbFactory.GetContext(); - - IQueryable query = from series in db.Series orderby series.Title select series; - return query.Skip(offset).Take(limit).ToArray(); - } - public async Task GetSeriesChunk( int offset, int limit, - string searchText + string? searchText, + int[]? genreIds ) { - searchText = searchText.ToLower(); - await using MetadataDbContext db = await _dbFactory.GetContext(); - IQueryable query = from series in db.Series orderby series.Title where series.Title.ToLower().Contains(searchText) select series; - return query.Skip(offset).Take(limit).ToArray(); - } + IQueryable query = db.Series.AsQueryable(); - public async Task GetSeriesChunk( - int offset, - int limit, - string searchText, - int[] genreIds - ) - { - searchText = searchText.ToLower(); + if (searchText is not null) + { + query = query.Where(s => s.Title.Contains(searchText)); + } - await using MetadataDbContext db = await _dbFactory.GetContext(); + if (genreIds is not null) + { + query = from s in query where db.GenresToSeries.Any(gs => gs.SeriesId == s.SeriesId && genreIds.Contains(gs.GenreId)) select s; + } - IQueryable query = from series in db.Series join genre in db.GenresToSeries on series.SeriesId equals genre.GenreId orderby series.Title where series.Title.ToLower().Contains(searchText) && genreIds.Contains(genre.GenreId) select series; - return query.Skip(offset).Take(limit).ToArray(); + return query + .OrderBy(s => s.Title) + .Skip(offset) + .Take(limit) + .ToArray(); } public Task GetStartedSeries() From 95be33dd569d1c1b3f3caeae2050ec0a8594c369 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Wed, 22 Apr 2026 12:09:46 +0200 Subject: [PATCH 021/134] Implement Series controller --- .../AniStream/Controllers/SeriesController.cs | 59 +++++++++++++++++++ server/AniStream/DTO/SeriesFilterModel.cs | 11 ++++ server/AniStream/DTO/SeriesModel.cs | 29 +++++++++ 3 files changed, 99 insertions(+) create mode 100644 server/AniStream/Controllers/SeriesController.cs create mode 100644 server/AniStream/DTO/SeriesFilterModel.cs create mode 100644 server/AniStream/DTO/SeriesModel.cs diff --git a/server/AniStream/Controllers/SeriesController.cs b/server/AniStream/Controllers/SeriesController.cs new file mode 100644 index 0000000..08d1a5a --- /dev/null +++ b/server/AniStream/Controllers/SeriesController.cs @@ -0,0 +1,59 @@ +using AniStream.API.DTO; +using AniStream.API.Utils; +using AniStream.Contracts; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AniStream.API.Controllers; + +[Route("{provider}/series")] +[ApiController] +[Authorize] +public class SeriesController : ApiControllerBase +{ + private ISeriesService _seriesService; + + public SeriesController(ISeriesService seriesService) + { + _seriesService = seriesService; + } + + [HttpGet] + public async Task> GetSeriesByIds(int[] seriesIds) + { + Models.SeriesModel[] series = await _seriesService.GetSeriesByIds(seriesIds); + + return series.Select(series => series.ToDTO()).ToArray(); + } + + [HttpPost] + public async Task GetSeriesChunk([FromBody] SeriesFilterModel options) + { + Models.SeriesModel[] series = await _seriesService.GetSeriesChunk(options.Offset, options.Limit, options.SearchText, options.GenreIds); + return series.Select(series => series.ToDTO()).ToArray(); + } + + [HttpGet("{seriesId}")] + public async Task> GetSeries(int seriesId) + { + Models.SeriesModel? series = await _seriesService.GetSeries(seriesId); + if (series is null) + { + return NotFound($"Series with ID '{seriesId}' not found"); + } + + return series.ToDTO(); + } + + [HttpGet("guid/{guid}")] + public async Task> GetSeries(string guid) + { + Models.SeriesModel? series = await _seriesService.GetSeries(guid); + if (series is null) + { + return NotFound($"Series with GUID '{guid}' not found"); + } + + return series.ToDTO(); + } +} \ No newline at end of file diff --git a/server/AniStream/DTO/SeriesFilterModel.cs b/server/AniStream/DTO/SeriesFilterModel.cs new file mode 100644 index 0000000..1e9e5f4 --- /dev/null +++ b/server/AniStream/DTO/SeriesFilterModel.cs @@ -0,0 +1,11 @@ +namespace AniStream.API.DTO; + +public sealed class SeriesFilterModel +{ + public required int Offset { get; set; } + public required int Limit { get; set; } + + public required string? SearchText { get; set; } + + public required int[]? GenreIds { get; set; } +} \ No newline at end of file diff --git a/server/AniStream/DTO/SeriesModel.cs b/server/AniStream/DTO/SeriesModel.cs new file mode 100644 index 0000000..f38ccff --- /dev/null +++ b/server/AniStream/DTO/SeriesModel.cs @@ -0,0 +1,29 @@ +namespace AniStream.API.DTO; + +public sealed class SeriesModel +{ + public required int SeriesId { get; set; } + + public required string Guid { get; set; } + + public required string Title { get; set; } + + public required string Description { get; set; } + + public required string? PreviewImage { get; set; } +} + +internal static class SeriesModelHelper +{ + public static SeriesModel ToDTO(this Models.SeriesModel model) + { + return new SeriesModel + { + SeriesId = model.SeriesId, + Guid = model.Guid, + Title = model.Title, + Description = model.Description, + PreviewImage = model.PreviewImage + }; + } +} \ No newline at end of file From fec27158857aee68138c9e848072076e2ad2e2f8 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Wed, 22 Apr 2026 12:25:35 +0200 Subject: [PATCH 022/134] Add snake policy for scalar --- server/AniStream/Program.cs | 27 +++++++++++++++++++++-- server/AniStream/Utils/SnakeCasePolicy.cs | 17 ++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 server/AniStream/Utils/SnakeCasePolicy.cs diff --git a/server/AniStream/Program.cs b/server/AniStream/Program.cs index 0454976..c83d725 100644 --- a/server/AniStream/Program.cs +++ b/server/AniStream/Program.cs @@ -8,6 +8,7 @@ using Microsoft.OpenApi.Models; using Microsoft.AspNetCore.Mvc; using AniStream.API.Middelware; +using AniStream.Utils; namespace AniStream.API; @@ -19,7 +20,11 @@ public static void Main(string[] args) WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - builder.Services.AddControllers(); + builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = new SnakeCasePolicy(); + }); builder.Services.Configure(options => { options.ModelMetadataDetailsProviders.Add(new EmptyStringEnabledDisplayMetadataProvider()); }); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => @@ -54,11 +59,29 @@ public static void Main(string[] args) { Type = SecuritySchemeType.ApiKey, In = ParameterLocation.Cookie, - Name = ".AspNetCore.Cookies" + Name = ".AniStream.Cookies" }; return Task.CompletedTask; }); + options.AddSchemaTransformer((schema, context, cancellationToken) => + { + if (schema.Properties is null) + { + return Task.CompletedTask; + } + + Dictionary updated = new Dictionary(); + + foreach (var (key, propSchema) in schema.Properties) + { + string snake = SnakeCaseConvention.ToSnakeCase(key); + updated[snake] = propSchema; + } + + schema.Properties = updated; + return Task.CompletedTask; + }); }); builder.Services.AddEndpointsApiExplorer(); SetupDependencyInjection(builder); diff --git a/server/AniStream/Utils/SnakeCasePolicy.cs b/server/AniStream/Utils/SnakeCasePolicy.cs new file mode 100644 index 0000000..a780d7c --- /dev/null +++ b/server/AniStream/Utils/SnakeCasePolicy.cs @@ -0,0 +1,17 @@ +using System.Text.Json; +using AniStream.Utils; + +namespace AniStream.API.Utils; + +public class SnakeCasePolicy : JsonNamingPolicy +{ + public override string ConvertName(string name) + { + if (string.IsNullOrEmpty(name)) + { + return name; + } + + return SnakeCaseConvention.ToSnakeCase(name); + } +} \ No newline at end of file From 401105425b1557a63571c82ded311b69f8eba39f Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Wed, 22 Apr 2026 12:35:20 +0200 Subject: [PATCH 023/134] Reformat file --- server/AniStream/DTO/SeriesFilterModel.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/server/AniStream/DTO/SeriesFilterModel.cs b/server/AniStream/DTO/SeriesFilterModel.cs index 1e9e5f4..89942b0 100644 --- a/server/AniStream/DTO/SeriesFilterModel.cs +++ b/server/AniStream/DTO/SeriesFilterModel.cs @@ -3,6 +3,7 @@ namespace AniStream.API.DTO; public sealed class SeriesFilterModel { public required int Offset { get; set; } + public required int Limit { get; set; } public required string? SearchText { get; set; } From 615532683e3fd486f4a4493d9dedc7bb4cf73d8e Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Wed, 22 Apr 2026 13:43:45 +0200 Subject: [PATCH 024/134] Implement Season service --- .../Contracts/ISeasonService.cs | 12 +++++ .../AniStream.Services/Services/AutoLoader.cs | 1 + .../Services/SeasonServiceImpl.cs | 44 +++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 server/AniStream.Services/Contracts/ISeasonService.cs create mode 100644 server/AniStream.Services/Services/SeasonServiceImpl.cs diff --git a/server/AniStream.Services/Contracts/ISeasonService.cs b/server/AniStream.Services/Contracts/ISeasonService.cs new file mode 100644 index 0000000..2924b1c --- /dev/null +++ b/server/AniStream.Services/Contracts/ISeasonService.cs @@ -0,0 +1,12 @@ +using AniStream.Models; + +namespace AniStream.Contracts; + +public interface ISeasonService +{ + public Task GetSeason(int seasonId); + + public Task GetSeasons(int seriesId); + + public Task CreateSeason(int seriesId, int seasonNumer); +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/AutoLoader.cs b/server/AniStream.Services/Services/AutoLoader.cs index 75bf9d4..b47ac7e 100644 --- a/server/AniStream.Services/Services/AutoLoader.cs +++ b/server/AniStream.Services/Services/AutoLoader.cs @@ -31,6 +31,7 @@ public static void LoadServices(IServiceCollection services, Options options) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } public struct Options diff --git a/server/AniStream.Services/Services/SeasonServiceImpl.cs b/server/AniStream.Services/Services/SeasonServiceImpl.cs new file mode 100644 index 0000000..f52cc60 --- /dev/null +++ b/server/AniStream.Services/Services/SeasonServiceImpl.cs @@ -0,0 +1,44 @@ +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Models; + +namespace AniStream.Services; + +public class SeasonServiceImpl : ISeasonService +{ + private MetadataDbContextFactory _dbFactory; + + public SeasonServiceImpl(MetadataDbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public async Task GetSeason(int seasonId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from season in db.Seasons where season.SeasonId == seasonId select season; + return query.FirstOrDefault(); + } + + public async Task GetSeasons(int seriesId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from season in db.Seasons where season.SeriesId == seriesId select season; + + return query.ToArray(); + } + + public async Task CreateSeason(int seriesId, int seasonNumber) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + SeasonModel season = new SeasonModel(seriesId, seasonNumber); + db.Seasons.Add(season); + + await db.SaveChangesAsync(); + + return season; + } +} \ No newline at end of file From 109f701cccd4b007b872dc2b3bdf41e3b881308a Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Wed, 22 Apr 2026 13:57:30 +0200 Subject: [PATCH 025/134] Implement Season routes --- .../AniStream/Controllers/SeasonController.cs | 39 +++++++++++++++++++ server/AniStream/DTO/SeasonModel.cs | 23 +++++++++++ 2 files changed, 62 insertions(+) create mode 100644 server/AniStream/Controllers/SeasonController.cs create mode 100644 server/AniStream/DTO/SeasonModel.cs diff --git a/server/AniStream/Controllers/SeasonController.cs b/server/AniStream/Controllers/SeasonController.cs new file mode 100644 index 0000000..c669ea2 --- /dev/null +++ b/server/AniStream/Controllers/SeasonController.cs @@ -0,0 +1,39 @@ +using AniStream.API.DTO; +using AniStream.API.Utils; +using AniStream.Contracts; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AniStream.API.Controllers; + +[Route("{provider}/seasons")] +[ApiController] +[Authorize] +public class SeasonController : ApiControllerBase +{ + private readonly ISeasonService _seasonService; + + public SeasonController(ISeasonService seasonService) + { + _seasonService = seasonService; + } + + [HttpGet("{seasonId}")] + public async Task> GetSeason(int seasonId) + { + Models.SeasonModel? season = await _seasonService.GetSeason(seasonId); + if (season is null) + { + return NotFound($"Season with ID '{seasonId}' not found"); + } + + return Ok(season.ToDTO()); + } + + [HttpGet("series/{seriesId}")] + public async Task GetSeasonsOfSeries(int seriesId) + { + Models.SeasonModel[] seasons = await _seasonService.GetSeasons(seriesId); + return seasons.Select(season => season.ToDTO()).ToArray(); + } +} \ No newline at end of file diff --git a/server/AniStream/DTO/SeasonModel.cs b/server/AniStream/DTO/SeasonModel.cs new file mode 100644 index 0000000..8699553 --- /dev/null +++ b/server/AniStream/DTO/SeasonModel.cs @@ -0,0 +1,23 @@ +namespace AniStream.API.DTO; + +public class SeasonModel +{ + public required int SeasonId { get; set; } + + public required int SeriesId { get; set; } + + public required int SeasonNumber { get; set; } +} + +internal static class SeasonModelHelper +{ + public static SeasonModel ToDTO(this Models.SeasonModel model) + { + return new SeasonModel() + { + SeasonId = model.SeasonId, + SeriesId = model.SeriesId, + SeasonNumber = model.SeasonNumber + }; + } +} \ No newline at end of file From 60dcd15c2e6cd32d40b073b29a076a87491b1f76 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Wed, 22 Apr 2026 14:45:53 +0200 Subject: [PATCH 026/134] Add missing property --- server/AniStream.Models/Models/EpisodeModel.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/AniStream.Models/Models/EpisodeModel.cs b/server/AniStream.Models/Models/EpisodeModel.cs index dbd4f4c..8fc7a4a 100644 --- a/server/AniStream.Models/Models/EpisodeModel.cs +++ b/server/AniStream.Models/Models/EpisodeModel.cs @@ -9,6 +9,8 @@ public sealed class EpisodeModel { public int EpisodeId { get; set; } + public int SeasonId { get; set; } + public int EpisodeNumber { get; set; } public string GermanTitle { get; set; } @@ -19,6 +21,7 @@ public sealed class EpisodeModel public EpisodeModel( int episodeId, + int seasonId, int episodeNumber, string germanTitle, string englishTitle, @@ -26,6 +29,7 @@ string description ) { EpisodeId = episodeId; + SeasonId = seasonId; EpisodeNumber = episodeNumber; GermanTitle = germanTitle; EnglishTitle = englishTitle; @@ -34,11 +38,13 @@ string description public EpisodeModel( int episodeNumber, + int seasonId, string germanTitle, string englishTitle, string description ) : this( 0, + seasonId, episodeNumber, germanTitle, englishTitle, From d93473542465010a8e473e7f0b1fc16681778a99 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Wed, 22 Apr 2026 14:46:02 +0200 Subject: [PATCH 027/134] Implement episode service --- .../Contracts/IEpisodeService.cs | 37 ++++++ .../Services/EpisodeServiceImpl.cs | 113 ++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 server/AniStream.Services/Contracts/IEpisodeService.cs create mode 100644 server/AniStream.Services/Services/EpisodeServiceImpl.cs diff --git a/server/AniStream.Services/Contracts/IEpisodeService.cs b/server/AniStream.Services/Contracts/IEpisodeService.cs new file mode 100644 index 0000000..14927a0 --- /dev/null +++ b/server/AniStream.Services/Contracts/IEpisodeService.cs @@ -0,0 +1,37 @@ +using AniStream.Models; + +namespace AniStream.Contracts; + +public interface IEpisodeService +{ + public Task GetEpisode(int episodeId); + + public Task GetEpisodes(int seasonId); + + public Task CreateEpisode( + int seasonId, + int episodeNumber, + string germanTitle, + string englishTitle, + string description + ); + + public Task UpdateEpisode( + int episodeId, + int? seasonId = null, + int? episodeNumber = null, + string? germanTitle = null, + string? englishTitle = null, + string? description = null + ); + + public Task UpdateEpisode( + EpisodeModel episode, + int? seasonId = null, + int? episodeNumber = null, + string? germanTitle = null, + string? englishTitle = null, + string? description = null + ); + +} diff --git a/server/AniStream.Services/Services/EpisodeServiceImpl.cs b/server/AniStream.Services/Services/EpisodeServiceImpl.cs new file mode 100644 index 0000000..c3fe552 --- /dev/null +++ b/server/AniStream.Services/Services/EpisodeServiceImpl.cs @@ -0,0 +1,113 @@ +using System.Data.Common; +using System.Reflection.Metadata.Ecma335; +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Models; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Services; + +class EpisodeServiceImpl : IEpisodeService +{ + private readonly MetadataDbContextFactory _dbFactory; + + public EpisodeServiceImpl(MetadataDbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public async Task CreateEpisode(int seasonId, int episodeNumber, string germanTitle, string englishTitle, string description) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + EpisodeModel episode = new EpisodeModel(seasonId, episodeNumber, germanTitle, englishTitle, description); + + db.Episodes.Add(episode); + await db.SaveChangesAsync(); + + return episode; + } + + public async Task GetEpisode(int episodeId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from episode in db.Episodes where episode.EpisodeId == episodeId select episode; + + return query.FirstOrDefault(); + } + + public async Task GetEpisodes(int seasonId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from episode in db.Episodes where episode.SeasonId == seasonId select episode; + + return query.ToArray(); + } + + public async Task UpdateEpisode( + int episodeId, + int? seasonId = null, + int? episodeNumber = null, + string? germanTitle = null, + string? englishTitle = null, + string? description = null + ) + { + EpisodeModel? episode = await GetEpisode(episodeId); + if (episode is null) + { + throw new ArgumentException($"Episode with ID '{episodeId}' dont exist", nameof(episodeId)); + } + + return await UpdateEpisode( + episode, + seasonId, + episodeNumber, + germanTitle, + englishTitle, + description + ); + } + + public async Task UpdateEpisode( + EpisodeModel episode, + int? seasonId = null, + int? episodeNumber = null, + string? germanTitle = null, + string? englishTitle = null, + string? description = null + ) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + if (seasonId is not null) + { + episode.SeasonId = (int)seasonId; + } + if (episodeNumber is not null) + { + episode.EpisodeNumber = (int)episodeNumber; + } + if (germanTitle is not null) + { + episode.GermanTitle = germanTitle; + } + if (englishTitle is not null) + { + episode.EnglishTitle = englishTitle; + } + if (description is not null) + { + episode.Description = description; + } + + db.Episodes.Update(episode); + await db.SaveChangesAsync(); + + return episode; + } + + +} \ No newline at end of file From 3b9f8e2205da4d65a70ce1fae1737cbfd312c7b1 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Sat, 25 Apr 2026 13:56:29 +0200 Subject: [PATCH 028/134] Better http layer --- app/src/providers/aniworld/fetcher.ts | 14 +-- app/src/providers/sto/fetcher.ts | 14 +-- app/src/sources/doodstream.ts | 4 +- app/src/sources/filemoon.ts | 4 +- app/src/sources/loadx.ts | 2 +- app/src/sources/luluvdo.ts | 2 +- app/src/sources/speedfiles.ts | 2 +- app/src/sources/vidmoly.ts | 4 +- app/src/sources/vidoza.ts | 2 +- app/src/sources/voe.ts | 6 +- app/src/utils/http.ts | 127 +++++++++++++++++++------- 11 files changed, 122 insertions(+), 59 deletions(-) diff --git a/app/src/providers/aniworld/fetcher.ts b/app/src/providers/aniworld/fetcher.ts index 5258f2c..cb8979a 100644 --- a/app/src/providers/aniworld/fetcher.ts +++ b/app/src/providers/aniworld/fetcher.ts @@ -24,7 +24,7 @@ export class AniWorldFetcher implements IInformationFetcher { } public async getCatalog(): Promise { - const html: string = await http.get(this.provider.catalogURL); + const html: string = await http.get(this.provider.catalogURL).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const seriesList: HTMLUListElement | null = document.querySelector("#seriesContainer ul"); if (!seriesList) { @@ -47,7 +47,7 @@ export class AniWorldFetcher implements IInformationFetcher { } public async getSeries(guid: string): Promise<[model: SeriesFetchModel, genres: GenreFetchModel[]]> { - const html: string = await http.get(this.provider.streamURL(guid)); + const html: string = await http.get(this.provider.streamURL(guid)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const informationPanel: HTMLElement | null = document.querySelector("#series"); @@ -68,7 +68,7 @@ export class AniWorldFetcher implements IInformationFetcher { let previewImage: string | null = null; if (previewImageElement && previewImageElement.hasAttribute("data-src")) { const url: string = previewImageElement.getAttribute("data-src")!.substring(1); - const binary: Uint8Array = await http.getBinary(`${this.provider.baseURL}/${url}`); + const binary: Uint8Array = await http.get(`${this.provider.baseURL}/${url}`).uint8Array(); previewImage = hash.fnv1a(guid); const storageLocation: string = await this.provider.getStorageLocation(); @@ -99,7 +99,7 @@ export class AniWorldFetcher implements IInformationFetcher { } public async getSeasons(series: SeriesModel): Promise { - const html: string = await http.get(this.provider.streamURL(series.guid)); + const html: string = await http.get(this.provider.streamURL(series.guid)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const streamPanel: HTMLElement | null = document.querySelector("#stream"); if (!streamPanel) { @@ -134,7 +134,7 @@ export class AniWorldFetcher implements IInformationFetcher { } public async getEpisodes(guid: string, seasonNumber: number): Promise { - const html: string = await http.get(this.provider.seasonURL(guid, seasonNumber)); + const html: string = await http.get(this.provider.seasonURL(guid, seasonNumber)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const tableBody: HTMLTableSectionElement | null = document.querySelector(`#season${seasonNumber}`); @@ -170,7 +170,7 @@ export class AniWorldFetcher implements IInformationFetcher { } private async fetchDescription(guid: string, seasonNumber: number, episodeNumber: number): Promise { - const html: string = await http.get(this.provider.episodeURL(guid, seasonNumber, episodeNumber)); + const html: string = await http.get(this.provider.episodeURL(guid, seasonNumber, episodeNumber)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const descriptionPanel: HTMLElement | null = document.querySelector("#wrapper div.hosterSiteTitle p.descriptionSpoiler"); if (!descriptionPanel) { @@ -181,7 +181,7 @@ export class AniWorldFetcher implements IInformationFetcher { } public async fetchProviders(guid: string, seasonNumber: number, episodeNumber: number): Promise { - const html: string = await http.get(this.provider.episodeURL(guid, seasonNumber, episodeNumber)); + const html: string = await http.get(this.provider.episodeURL(guid, seasonNumber, episodeNumber)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const row: HTMLUListElement | null = document.querySelector(".hosterSiteVideo ul.row"); diff --git a/app/src/providers/sto/fetcher.ts b/app/src/providers/sto/fetcher.ts index fb58bc6..b9b4672 100644 --- a/app/src/providers/sto/fetcher.ts +++ b/app/src/providers/sto/fetcher.ts @@ -24,7 +24,7 @@ export class StoFetcher implements IInformationFetcher { } public async getCatalog(): Promise { - const html: string = await http.get(this.provider.catalogURL); + const html: string = await http.get(this.provider.catalogURL).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const seriesLists: NodeListOf = document.querySelectorAll("ul.series-list"); if (seriesLists.length == 0) { @@ -49,7 +49,7 @@ export class StoFetcher implements IInformationFetcher { } public async getSeries(guid: string): Promise<[model: SeriesFetchModel, genres: GenreFetchModel[]]> { - const html: string = await http.get(this.provider.streamURL(guid)); + const html: string = await http.get(this.provider.streamURL(guid)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const titleElement: HTMLElement | null = document.querySelector("div.show-header-wrapper > div.container-fluid.px-2.px-md-3.px-lg-3.px-xl-4.position-relative > div.row.g-4.mb-2 > div.col-12.col-md-9.col-lg-10.text-light > h1") @@ -71,7 +71,7 @@ export class StoFetcher implements IInformationFetcher { } url = `${this.provider.baseURL}${url}`; } - const binary: Uint8Array = await http.getBinary(url); + const binary: Uint8Array = await http.get(url).uint8Array(); previewImage = hash.fnv1a(guid); const storageLocation: string = await this.provider.getStorageLocation(); @@ -95,7 +95,7 @@ export class StoFetcher implements IInformationFetcher { } public async getSeasons(series: SeriesModel): Promise { - const html: string = await http.get(this.provider.streamURL(series.guid)); + const html: string = await http.get(this.provider.streamURL(series.guid)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const seasonElements: NodeListOf = document.querySelectorAll("#season-nav > ul a") @@ -121,7 +121,7 @@ export class StoFetcher implements IInformationFetcher { } public async getEpisodes(guid: string, seasonNumber: number): Promise { - const html: string = await http.get(this.provider.seasonURL(guid, seasonNumber)); + const html: string = await http.get(this.provider.seasonURL(guid, seasonNumber)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const tableBody: HTMLTableSectionElement | null = document.querySelector(`table.episode-table`); @@ -161,7 +161,7 @@ export class StoFetcher implements IInformationFetcher { } private async fetchDescription(guid: string, seasonNumber: number, episodeNumber: number): Promise { - const html: string = await http.get(this.provider.episodeURL(guid, seasonNumber, episodeNumber)); + const html: string = await http.get(this.provider.episodeURL(guid, seasonNumber, episodeNumber)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const descriptionPanel: HTMLElement | null = document.querySelector("[id^='desc-'] > div"); if (!descriptionPanel) { @@ -172,7 +172,7 @@ export class StoFetcher implements IInformationFetcher { } public async fetchProviders(guid: string, seasonNumber: number, episodeNumber: number): Promise { - const html: string = await http.get(this.provider.episodeURL(guid, seasonNumber, episodeNumber)); + const html: string = await http.get(this.provider.episodeURL(guid, seasonNumber, episodeNumber)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const providerElements: NodeListOf = document.querySelectorAll("#episode-links button"); diff --git a/app/src/sources/doodstream.ts b/app/src/sources/doodstream.ts index 7cdab20..b6a01cf 100644 --- a/app/src/sources/doodstream.ts +++ b/app/src/sources/doodstream.ts @@ -38,10 +38,10 @@ export async function getStream(embedUrl: string): Promise { throw "Embed URL cannot be empty"; } - const html: string = await http.get(embedUrl, getHeaders()); + const html: string = await http.get(embedUrl, getHeaders()).text(); const md5Url: string = getPassMd5Url(html, embedUrl); const token: string = getToken(html, embedUrl); - const md5Html: string = await http.get(md5Url, getHeaders()); + const md5Html: string = await http.get(md5Url, getHeaders()).text(); const videoBaseUrl: string = md5Html.trim(); if (!videoBaseUrl) { throw `Empty video base URL returned from ${embedUrl}`; diff --git a/app/src/sources/filemoon.ts b/app/src/sources/filemoon.ts index 311b8be..41a517c 100644 --- a/app/src/sources/filemoon.ts +++ b/app/src/sources/filemoon.ts @@ -127,7 +127,7 @@ async function tryByseApi( const baseUrl = `${parsed.protocol}//${parsed.host}`; const apiUrl = `${baseUrl}/api/videos/${fileCode}`; - const content: string = await http.get(apiUrl, getHeaders()); + const content: string = await http.get(apiUrl, getHeaders()).text(); const data = JSON.parse(content); const playback = data?.playback; if (!playback) return null; @@ -213,7 +213,7 @@ export async function getStream(embedUrl: string): Promise { } } - const html: string = await http.get(embedUrl, getHeaders()); + const html: string = await http.get(embedUrl, getHeaders()).text(); const url: string | null = tryExtractFromHtml(html); if (url) { diff --git a/app/src/sources/loadx.ts b/app/src/sources/loadx.ts index 611274c..b51c417 100644 --- a/app/src/sources/loadx.ts +++ b/app/src/sources/loadx.ts @@ -88,7 +88,7 @@ export async function getStream(embeddedLoadXLink: string): Promise { const [idHash, host] = extractIdHashFromURL(response.url); const postURL: string = `https://${host}/player/index.php?data=${idHash}&do=getVideo`; - const apiResponse: string = await http.post(postURL, [["X-Requested-With", "XMLHttpRequest"]]); + const apiResponse: string = await http.post(postURL, null, [["X-Requested-With", "XMLHttpRequest"]]).text(); return parseVideoResponse(apiResponse); } catch (error) { diff --git a/app/src/sources/luluvdo.ts b/app/src/sources/luluvdo.ts index 1284ce3..49a2809 100644 --- a/app/src/sources/luluvdo.ts +++ b/app/src/sources/luluvdo.ts @@ -122,7 +122,7 @@ export async function getStream(embeddedLuluvdoLink: string): Promise { const embedURL: string = buildEmbedURL(luluvdoId); const headers: [string, string][] = buildHeaders(); - const html: string = await http.get(embedURL, headers); + const html: string = await http.get(embedURL, headers).text(); return extractVideoURL(html); } catch (error) { throw "Failed to extract video from LuluVDO: " + error; diff --git a/app/src/sources/speedfiles.ts b/app/src/sources/speedfiles.ts index fa93818..fc93712 100644 --- a/app/src/sources/speedfiles.ts +++ b/app/src/sources/speedfiles.ts @@ -102,7 +102,7 @@ export async function getStream(embeddedSpeedFilesLink: string): Promise try { const validatedURL: string = validateSpeedFilesURL(embeddedSpeedFilesLink); - const html: string = await http.get(validatedURL, []); + const html: string = await http.get(validatedURL, []).text(); checkServerStatus(html); const encodedData: string = extractEncodedData(html); diff --git a/app/src/sources/vidmoly.ts b/app/src/sources/vidmoly.ts index fbd468a..5d6e733 100644 --- a/app/src/sources/vidmoly.ts +++ b/app/src/sources/vidmoly.ts @@ -12,7 +12,7 @@ const FILE_LINK_PATTERN: RegExp = /file:\s*"(https?:\/\/[^"]+)"/; */ export async function getStream(embeddedVidmolyLink: string): Promise { try { - const html: string = await http.get(embeddedVidmolyLink); + const html: string = await http.get(embeddedVidmolyLink).text(); const match: RegExpMatchArray | null = html.match(FILE_LINK_PATTERN); if (match) { @@ -43,7 +43,7 @@ export async function getStream(embeddedVidmolyLink: string): Promise { */ export async function getPreviewImage(embeddedVidmolyLink: string): Promise { try { - const html: string = await http.get(embeddedVidmolyLink); + const html: string = await http.get(embeddedVidmolyLink).text(); const match: RegExpMatchArray | null = html.match(/image\s*:\s*"([^"]+\.jpg)"/); if (match) { diff --git a/app/src/sources/vidoza.ts b/app/src/sources/vidoza.ts index cf92c00..99de630 100644 --- a/app/src/sources/vidoza.ts +++ b/app/src/sources/vidoza.ts @@ -12,7 +12,7 @@ const SOURCE_LINK_PART: RegExp = /src:\s*"([^"]+)"/; */ export async function getStream(embeddedVidozaLink: string): Promise { try { - const html: string = await http.get(embeddedVidozaLink); + const html: string = await http.get(embeddedVidozaLink).text(); if (html.search("sourcesCode:") != -1) { const match: RegExpMatchArray | null = html.match(SOURCE_LINK_PART); if (match) { diff --git a/app/src/sources/voe.ts b/app/src/sources/voe.ts index d7122a0..c2a487e 100644 --- a/app/src/sources/voe.ts +++ b/app/src/sources/voe.ts @@ -99,7 +99,7 @@ function extractVOEFromScript(html: string): string | null { export async function getStream(embeddedVOELink: string): Promise { try { // Initial request to get redirect URL - const response: string = await http.get(embeddedVOELink); + const response: string = await http.get(embeddedVOELink).text(); // Find redirect URL using compiled regex const redirectMatch: RegExpMatchArray | null = response.match(REDIRECT_PATTERN); @@ -119,7 +119,7 @@ export async function getStream(embeddedVOELink: string): Promise { // Follow redirect and get final HTML try { - html = await http.get(redirectURL); + html = await http.get(redirectURL).text(); } catch (e) { throw `Failed to follow redirect: ${e}`; } @@ -169,7 +169,7 @@ export async function getStream(embeddedVOELink: string): Promise { export async function getPreviewImage(embeddedVOELink: string): Promise { try { // Initial request to get redirect URL - const response: string = await http.get(embeddedVOELink); + const response: string = await http.get(embeddedVOELink).text(); // Find redirect URL using compiled regex const redirectMatch: RegExpMatchArray | null = response.match(REDIRECT_PATTERN); diff --git a/app/src/utils/http.ts b/app/src/utils/http.ts index 45fb525..5267142 100644 --- a/app/src/utils/http.ts +++ b/app/src/utils/http.ts @@ -1,62 +1,125 @@ import {fetch} from "@tauri-apps/plugin-http"; -export async function get(url: string, headers: [string, string][] = []): Promise { - const response: Response = await fetch(url, { - method: "GET", - headers: headers - }); +export class HTTPError extends Error { + public readonly response: Response; + public readonly status: number; + public readonly statusText: string; + public readonly url: string; + public readonly body: any; - if (!response.ok) { - throw `Request failed: HTTP response status ${response.status}`; + private constructor( + message: string, + response: Response + ) { + super(message); + this.name = 'HTTPError'; + + this.response = response; + this.status = response.status; + this.statusText = response.statusText; + this.url = response.url; } - return await response.text(); -} + public static async create(response: Response): Promise { + let message: string = `HTTP ${response.status}: ${response.statusText} (${response.url})`; -export async function getBinary(url: string, headers: [string, string][] = []): Promise { - const response: Response = await fetch(url, { - method: "GET", - headers: headers - }); + try { + let body: string; + const contentType: string | null = response.headers.get("content-type"); + if (contentType && contentType.includes("application/json")) { + body = JSON.stringify(await response.json(), null, 4); + } else { + body = await response.text(); + } - if (!response.ok) { - throw `Request failed: HTTP response status ${response.status}`; + message += "\n\n" + body; + } catch { + // If body parsing fails, we skip appending it + } + + + return new HTTPError(message, response); + } + + public [Symbol.toPrimitive](hint: string): string | number { + if (hint === 'number') return this.status; + return this.message; } - const buffer: ArrayBuffer = await response.arrayBuffer(); - return new Uint8Array(buffer); + public toString(): string { + return this.message; + } } -export async function head(url: string, headers: [string, string][], followRedirect: boolean = true): Promise { - const response: Response = await fetch(url, { - method: "HEAD", - headers: headers, - redirect: followRedirect ? "follow" : undefined - }); +class HTTPResponse { + private readonly promise: Promise; - if (!response.ok) { - throw `Request failed: HTTP response status ${response.status}`; + public constructor(promise: Promise) { + this.promise = promise; } - return await response; + public async text(): Promise { + return (await this.getResponse()).text(); + } + + public async json(): Promise { + return (await this.getResponse()).json(); + } + + public async arrayBuffer(): Promise { + return (await this.getResponse()).arrayBuffer(); + } + + public async uint8Array(): Promise { + return new Uint8Array(await this.arrayBuffer()); + } + + public async wait(): Promise { + await this.getResponse(); + } + + private async getResponse(): Promise { + const response: Response = await this.promise; + if (!response.ok) { + throw await HTTPError.create(response); + } + + return response; + } } -export async function post(url: string, headers: [string, string][]): Promise { - const response: Response = await fetch(url, { +export function get(url: string, headers: [string, string][] = []): HTTPResponse { + return new HTTPResponse(fetch(url, { + method: "GET", + headers: headers + })); +} + +export function post(url: string, body: RequestInit["body"], headers: [string, string][] = []): HTTPResponse { + return new HTTPResponse(fetch(url, { method: "POST", + body: body, headers: headers + })); +} + +export async function head(url: string, headers: [string, string][], followRedirect: boolean = true): Promise { + const response: Response = await fetch(url, { + method: "HEAD", + headers: headers, + redirect: followRedirect ? "follow" : undefined }); if (!response.ok) { - throw `Request failed: HTTP response status ${response.status}`; + throw await HTTPError.create(response); } - return await response.text(); + return response; } export async function runHealthz(healthzUrl: string): Promise { try { - await get(healthzUrl); + await get(healthzUrl).wait(); return true; } catch { return false; From 9f3cd58922ae01c5e2dcabe4622ee1a8acb3e3b1 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Sat, 25 Apr 2026 13:56:42 +0200 Subject: [PATCH 029/134] Implement base service --- app/src/services/client/utils/api.ts | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 app/src/services/client/utils/api.ts diff --git a/app/src/services/client/utils/api.ts b/app/src/services/client/utils/api.ts new file mode 100644 index 0000000..aa7fb43 --- /dev/null +++ b/app/src/services/client/utils/api.ts @@ -0,0 +1,49 @@ +import {ReadableGlobalContext} from "vue-mvvm"; + +import {SettingsService} from "@contracts/settings.contract"; + +import * as http from "@utils/http"; + +export type PathParts = Array; +export type QueryTypes = string | number | boolean; +export type PathParameter = PathParts[] | RouteBuilder; + +export interface RouteBuilder { + path: PathParts[]; + query: Record; +} + +export class ApiServiceBase { + protected readonly settingsService: SettingsService; + + protected constructor(ctx: ReadableGlobalContext) { + this.settingsService = ctx.getService(SettingsService); + } + + protected async get(def: PathParameter): Promise { + const url: string = this.buildURL(def); + return await http.get(url).json(); + } + + protected async post(def: PathParameter, body: Body): Promise { + const url: string = this.buildURL(def); + return await http.post(url, body).json(); + } + + private buildURL(def: PathParameter): string { + if (Array.isArray(def)) { + const base: string = "http://localhost:5000"; + const path: string = def.join("/"); + return `${base}/${path}`; + } + + const base: string = this.buildURL(def.path); + const query: string = Object.entries(def.query).map(([key, value]) => Array.isArray(value) + ? value.map(value => `${key}=${value}`).join("&") + : `${key}=${value}` + ).join("&"); + + return `${base}?${query}`; + } + +} \ No newline at end of file From 4fc21d28db6d32850e3df32ac1d1cedbcd980f4f Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Sat, 25 Apr 2026 18:23:43 +0200 Subject: [PATCH 030/134] Work on API --- .../Contracts/IGenreService.cs | 4 +- .../Contracts/IUserService.cs | 4 +- .../Services/GenreServiceImpl.cs | 26 ++++++++--- .../Services/UserServiceImpl.cs | 10 ++++- .../AniStream/Controllers/GenreController.cs | 30 +++++++++++-- .../Controllers/ProfileController.cs | 40 +++++++++++++++++ .../AniStream/Controllers/SeasonController.cs | 2 +- .../AniStream/Controllers/SeriesController.cs | 12 ++++- server/AniStream/DTO/ProfileModel.cs | 44 +++++++++++++++++++ .../AniStream/Services/CredentialsService.cs | 2 +- 10 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 server/AniStream/Controllers/ProfileController.cs create mode 100644 server/AniStream/DTO/ProfileModel.cs diff --git a/server/AniStream.Services/Contracts/IGenreService.cs b/server/AniStream.Services/Contracts/IGenreService.cs index 1f94ff0..a3d7d7e 100644 --- a/server/AniStream.Services/Contracts/IGenreService.cs +++ b/server/AniStream.Services/Contracts/IGenreService.cs @@ -10,7 +10,9 @@ public interface IGenreService public Task GetGenres(); - public Task GetGenreByKey(string key); + public Task GetGenre(int genreId); + + public Task GetGenre(string key); public Task GetMainGenreOfSeries(int seriesId); diff --git a/server/AniStream.Services/Contracts/IUserService.cs b/server/AniStream.Services/Contracts/IUserService.cs index 2d486b1..24aba51 100644 --- a/server/AniStream.Services/Contracts/IUserService.cs +++ b/server/AniStream.Services/Contracts/IUserService.cs @@ -8,5 +8,7 @@ public interface IUserService public Task GetProfiles(); - public Task GetProfileByUsernameOrDefault(string username); + public Task GetProfile(string username); + + public Task GetProfile(int profileId); } \ No newline at end of file diff --git a/server/AniStream.Services/Services/GenreServiceImpl.cs b/server/AniStream.Services/Services/GenreServiceImpl.cs index 9852f3c..3b40c15 100644 --- a/server/AniStream.Services/Services/GenreServiceImpl.cs +++ b/server/AniStream.Services/Services/GenreServiceImpl.cs @@ -32,7 +32,7 @@ public async Task CreateGenreToSeries(int genreId, int seriesId, bool mainGenre) GenreToSeries genreToSeries = new GenreToSeries(genreId, seriesId, mainGenre); db.GenresToSeries.Add(genreToSeries); - + await db.SaveChangesAsync(); } @@ -42,27 +42,41 @@ public async Task GetGenres() return await db.Genres.ToArrayAsync(); } - public async Task GetGenreByKey(string key) + public async Task GetGenre(int genreId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from genre in db.Genres where genre.GenreId == genreId select genre; + return await query.FirstOrDefaultAsync(); + } + + public async Task GetGenre(string key) { await using MetadataDbContext db = await _dbFactory.GetContext(); IQueryable query = from genre in db.Genres where genre.Key == key select genre; - return query.FirstOrDefault(); + return await query.FirstOrDefaultAsync(); } public async Task GetMainGenreOfSeries(int seriesId) { await using MetadataDbContext db = await _dbFactory.GetContext(); - IQueryable query = from gs in db.GenresToSeries join g in db.Genres on gs.GenreId equals g.GenreId where gs.SeriesId == seriesId && gs.MainGenre select g; - return query.FirstOrDefault(); + IQueryable query = from gs in db.GenresToSeries + join g in db.Genres on gs.GenreId equals g.GenreId + where gs.SeriesId == seriesId && gs.MainGenre + select g; + return await query.FirstOrDefaultAsync(); } public async Task GetNonMainGenresOfSeries(int seriesId) { await using MetadataDbContext db = await _dbFactory.GetContext(); - IQueryable query = from gs in db.GenresToSeries join g in db.Genres on gs.GenreId equals g.GenreId where gs.SeriesId == seriesId && !gs.MainGenre select g; + IQueryable query = from gs in db.GenresToSeries + join g in db.Genres on gs.GenreId equals g.GenreId + where gs.SeriesId == seriesId && !gs.MainGenre + select g; return await query.ToArrayAsync(); } } \ No newline at end of file diff --git a/server/AniStream.Services/Services/UserServiceImpl.cs b/server/AniStream.Services/Services/UserServiceImpl.cs index d67b0b6..e8d8c25 100644 --- a/server/AniStream.Services/Services/UserServiceImpl.cs +++ b/server/AniStream.Services/Services/UserServiceImpl.cs @@ -27,11 +27,19 @@ public async Task GetProfiles() return db.Profiles.ToArray(); } - public async Task GetProfileByUsernameOrDefault(string username) + public async Task GetProfile(string username) { await using ProfileDbContext db = await _dbFactory.GetContext(); IQueryable query = from profile in db.Profiles where profile.Name == username select profile; return await query.FirstOrDefaultAsync(); } + + public async Task GetProfile(int profileId) + { + await using ProfileDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from profile in db.Profiles where profile.ProfileId == profileId select profile; + return await query.FirstOrDefaultAsync(); + } } \ No newline at end of file diff --git a/server/AniStream/Controllers/GenreController.cs b/server/AniStream/Controllers/GenreController.cs index e282c42..3e20102 100644 --- a/server/AniStream/Controllers/GenreController.cs +++ b/server/AniStream/Controllers/GenreController.cs @@ -27,15 +27,39 @@ public async Task GetGenres() return genres.Select(genre => genre.ToDTO()).ToArray(); } - [HttpGet("{seriesId}")] + [HttpGet("{genreId}")] + public async Task> GetGenre(int genreId) + { + Models.GenreModel? genre = await _genreService.GetGenre(genreId); + if (genre is null) + { + return NotFound($"Genre with the ID '{genreId}' not found"); + } + + return Ok(genre.ToDTO()); + } + + [HttpGet("key/{genreKey}")] + public async Task> GetGenre(string genreKey) + { + Models.GenreModel? genre = await _genreService.GetGenre(genreKey); + if (genre is null) + { + return NotFound($"Genre with the key '{genreKey}' not found"); + } + + return Ok(genre.ToDTO()); + } + + [HttpGet("series/{seriesId}")] public async Task GetGenresOfSeries(int seriesId) { Models.GenreModel[] genres = await _genreService.GetNonMainGenresOfSeries(seriesId); - + return genres.Select(genre => genre.ToDTO()).ToArray(); } - [HttpGet("{seriesId}/main")] + [HttpGet("series/{seriesId}/main")] public async Task> GetMainGenreOfSeries(int seriesId) { Models.GenreModel? genre = await _genreService.GetMainGenreOfSeries(seriesId); diff --git a/server/AniStream/Controllers/ProfileController.cs b/server/AniStream/Controllers/ProfileController.cs new file mode 100644 index 0000000..3c544ad --- /dev/null +++ b/server/AniStream/Controllers/ProfileController.cs @@ -0,0 +1,40 @@ +using AniStream.API.DTO; +using AniStream.API.Utils; +using AniStream.Contracts; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AniStream.API.Controllers; + +[Route("/api/profiles")] +[ApiController] +[Authorize] +public sealed class ProfileController : ApiControllerBase +{ + private readonly IUserService _userService; + + public ProfileController(IUserService userService) + { + _userService = userService; + } + + [HttpGet] + public async Task> GetProfiles() + { + Models.ProfileModel[] profiles = await _userService.GetProfiles(); + + return profiles.Select(profile => profile.ToDTO()).ToArray(); + } + + [HttpGet("{profileId}")] + public async Task> GetProfile(int profileId) + { + Models.ProfileModel? profile = await _userService.GetProfile(profileId); + if (profile is null) + { + return NotFound($"Profile with ID '{profileId}' not found"); + } + + return Ok(profile.ToDTO()); + } +} \ No newline at end of file diff --git a/server/AniStream/Controllers/SeasonController.cs b/server/AniStream/Controllers/SeasonController.cs index c669ea2..36c670a 100644 --- a/server/AniStream/Controllers/SeasonController.cs +++ b/server/AniStream/Controllers/SeasonController.cs @@ -6,7 +6,7 @@ namespace AniStream.API.Controllers; -[Route("{provider}/seasons")] +[Route("api/{provider}/seasons")] [ApiController] [Authorize] public class SeasonController : ApiControllerBase diff --git a/server/AniStream/Controllers/SeriesController.cs b/server/AniStream/Controllers/SeriesController.cs index 08d1a5a..bb0db8d 100644 --- a/server/AniStream/Controllers/SeriesController.cs +++ b/server/AniStream/Controllers/SeriesController.cs @@ -6,7 +6,7 @@ namespace AniStream.API.Controllers; -[Route("{provider}/series")] +[Route("api/{provider}/series")] [ApiController] [Authorize] public class SeriesController : ApiControllerBase @@ -19,7 +19,7 @@ public SeriesController(ISeriesService seriesService) } [HttpGet] - public async Task> GetSeriesByIds(int[] seriesIds) + public async Task> GetSeriesByIds([FromQuery] int[] seriesIds) { Models.SeriesModel[] series = await _seriesService.GetSeriesByIds(seriesIds); @@ -56,4 +56,12 @@ public async Task> GetSeries(string guid) return series.ToDTO(); } + + [HttpGet("started")] + public async Task> GetStartedSeries() + { + Models.SeriesModel[] series = await _seriesService.GetStartedSeries(); + + return series.Select(series => series.ToDTO()).ToArray(); + } } \ No newline at end of file diff --git a/server/AniStream/DTO/ProfileModel.cs b/server/AniStream/DTO/ProfileModel.cs new file mode 100644 index 0000000..150cbb2 --- /dev/null +++ b/server/AniStream/DTO/ProfileModel.cs @@ -0,0 +1,44 @@ +namespace AniStream.API.DTO; + +public sealed class ProfileModel +{ + public required int ProfileId { get; set; } + + public required string Uuid { get; set; } + + public required string Name { get; set; } + + public required string BackgroundColor { get; set; } + + public required string Eye { get; set; } + + public required string Mouth { get; set; } + + public required string Theme { get; set; } + + public required string Lang { get; set; } + + public required bool TosAccepted { get; set; } + + public required bool SyncCatalog { get; set; } +} + +internal static class ProfileModelHelper +{ + public static ProfileModel ToDTO(this Models.ProfileModel model) + { + return new ProfileModel + { + ProfileId = model.ProfileId, + Uuid = model.Uuid, + Name = model.Name, + BackgroundColor = model.BackgroundColor, + Eye = model.Eye, + Mouth = model.Mouth, + Theme = model.Theme, + Lang = model.Lang, + TosAccepted = model.TosAccepted, + SyncCatalog = model.SyncCatalog + }; + } +} \ No newline at end of file diff --git a/server/AniStream/Services/CredentialsService.cs b/server/AniStream/Services/CredentialsService.cs index 6252f6d..07a58ce 100644 --- a/server/AniStream/Services/CredentialsService.cs +++ b/server/AniStream/Services/CredentialsService.cs @@ -14,7 +14,7 @@ public CredentialsService(IUserService userService) public async Task ValidateCredentials(string username, string password) { - ProfileModel? profile = await _userService.GetProfileByUsernameOrDefault(username); + ProfileModel? profile = await _userService.GetProfile(username); if (profile is null) { return false; From 2d16c8bdd34ffad2fa3a60bdd32a4161db9e6097 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Sat, 25 Apr 2026 18:24:23 +0200 Subject: [PATCH 031/134] Connect backend with client services --- app/src/services/client/genre.service.ts | 95 ++++++++++++++ app/src/services/client/season.service.ts | 68 ++++++++++ app/src/services/client/series.service.ts | 150 ++++++++++++++++++++++ app/src/services/client/utils/api.ts | 16 ++- app/tsconfig.json | 2 +- 5 files changed, 326 insertions(+), 5 deletions(-) create mode 100644 app/src/services/client/genre.service.ts create mode 100644 app/src/services/client/season.service.ts create mode 100644 app/src/services/client/series.service.ts diff --git a/app/src/services/client/genre.service.ts b/app/src/services/client/genre.service.ts new file mode 100644 index 0000000..bd6b1ba --- /dev/null +++ b/app/src/services/client/genre.service.ts @@ -0,0 +1,95 @@ +import {ReadableGlobalContext} from "vue-mvvm"; + +import {ServiceDeclaration} from "@services/declaration"; +import {ApiServiceBase} from "@services/utils/api"; + +import {GenreService} from "@contracts/genre.contract"; +import {ProviderService} from "@contracts/provider.contract"; + +import {GenreDbModel, GenreModel} from "@models/genre.model"; +import {SeriesModel} from "@models/series.model"; + +import {DefaultProvider} from "@providers/default"; + +import {HTTPError} from "@utils/http"; + +class GenreServiceImpl extends ApiServiceBase implements GenreService { + private readonly providerService: ProviderService; + + public constructor(ctx: ReadableGlobalContext) { + super(ctx); + + this.providerService = ctx.getService(ProviderService); + } + + public async getGenreByKey(key: string): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + try { + const genre: GenreDbModel = await this.get(["api", provider.uniqueKey, "genres", "key", key]); + return GenreModel( + genre.genre_id, + genre.key + ); + } catch (e) { + if (e instanceof HTTPError && e.status == 404) { + return null; + } + + throw e; + } + } + + public async insertGenre(_key: string): Promise { + throw new Error("Method not implemented."); + } + + public async insertGenreToSeries(_genre: GenreModel, _series: SeriesModel, _main_genre: boolean): Promise { + throw new Error("Method not implemented."); + } + + public async getGenres(): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const genres: GenreDbModel[] = await this.get(["api", provider.uniqueKey, "genres"]); + + return genres.map(genre => GenreModel( + genre.genre_id, + genre.key + )); + } + + public async getMainGenreOfSeries(seriesId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + try { + const genre: GenreDbModel = await this.get(["api", provider.uniqueKey, "genres", "series", seriesId, "main"]); + + return GenreModel( + genre.genre_id, + genre.key + ); + } catch (e) { + if (e instanceof HTTPError && e.status == 404) { + return null; + } + + throw e; + } + } + + public async getNonMainGenresOfSeries(seriesId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const genres: GenreDbModel[] = await this.get(["api", provider.uniqueKey, "genres", "series", seriesId]); + + return genres.map(genre => GenreModel( + genre.genre_id, + genre.key + )); + } +} + +export default { + key: GenreService, + ctor: GenreServiceImpl +} satisfies ServiceDeclaration; \ No newline at end of file diff --git a/app/src/services/client/season.service.ts b/app/src/services/client/season.service.ts new file mode 100644 index 0000000..ae48827 --- /dev/null +++ b/app/src/services/client/season.service.ts @@ -0,0 +1,68 @@ +import {ReadableGlobalContext} from "vue-mvvm"; + +import {ApiServiceBase} from "@services/utils/api"; +import {ServiceDeclaration} from "@services/declaration"; + +import {SeasonService} from "@contracts/season.contract"; +import {ProviderService} from "@contracts/provider.contract"; + +import {SeasonDbModel, SeasonModel} from "@models/season.model"; + +import {DefaultProvider} from "@providers/default"; + +import {HTTPError} from "@utils/http"; + +class SeasonServiceImpl extends ApiServiceBase implements SeasonService { + private readonly providerService: ProviderService; + + public constructor(ctx: ReadableGlobalContext) { + super(ctx); + + this.providerService = ctx.getService(ProviderService); + } + + public requiresSync(_seriesId: number): Promise { + throw new Error("Method not implemented."); + } + + public async getSeason(seasonId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + try { + const season: SeasonDbModel = await this.get(["api", provider.uniqueKey, "seasons", seasonId]); + + return SeasonModel( + season.season_id, + season.series_id, + season.season_number + ); + } catch (e) { + if (e instanceof HTTPError && e.status == 404) { + return null; + } + + throw e; + } + } + + public async getSeasons(seriesId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const seasons: SeasonDbModel[] = await this.get(["api", provider.uniqueKey, "seasons", "series", seriesId]); + + return seasons.map(season => SeasonModel( + season.season_id, + season.series_id, + season.season_number + )); + } + + public insertSeason(_seriesId: number, _seasonNumber: number): Promise { + throw new Error("Method not implemented."); + } +} + +export default { + key: SeasonService, + ctor: SeasonServiceImpl +} satisfies ServiceDeclaration; \ No newline at end of file diff --git a/app/src/services/client/series.service.ts b/app/src/services/client/series.service.ts new file mode 100644 index 0000000..fbbbe86 --- /dev/null +++ b/app/src/services/client/series.service.ts @@ -0,0 +1,150 @@ +import {ReadableGlobalContext} from "vue-mvvm"; + +import {ApiServiceBase} from "@services/utils/api"; +import {ServiceDeclaration} from "@services/declaration"; + +import {SeriesService} from "@contracts/series.contract"; +import {ProviderService} from "@contracts/provider.contract"; + +import {SeriesDbModel, SeriesModel} from "@/models/series.model"; + +import {DefaultProvider} from "@providers/default"; +import {HTTPError} from "@utils/http"; + +interface SeriesFilterModel { + limit: number; + offset: number; + genre_ids: number[] | null; + search_text: string | null; +} + +class SeriesServiceImpl extends ApiServiceBase implements SeriesService { + private readonly providerService: ProviderService; + + public constructor(ctx: ReadableGlobalContext) { + super(ctx); + + this.providerService = ctx.getService(ProviderService); + } + + public requiresSync(): Promise { + throw new Error("Method not implemented."); + } + + public async existByGUID(guid: string): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + try { + await this.get(["api", provider.uniqueKey, "series", "guid", guid]); + return true; + } catch (e) { + if (e instanceof HTTPError && e.status == 404) { + return false; + } + + throw e; + } + } + + public async insertSeries(_guid: string, _title: string, _description: string, _previewImage: string | null): Promise { + throw new Error("Method not implemented."); + } + + public async getSeriesChunk(offset: number, limit: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const series: SeriesDbModel[] = await this.post(["api", provider.uniqueKey, "series"], { + offset: offset, + limit: limit, + genre_ids: null, + search_text: null + }); + + return series.map(series => SeriesModel( + series.series_id, + series.guid, + series.title, + series.description, + series.preview_image + )); + } + + public async getFilteredSeriesChunk(offset: number, limit: number, searchText: string, genresIds: number[]): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const series: SeriesDbModel[] = await this.post(["api", provider.uniqueKey, "series"], { + offset: offset, + limit: limit, + genre_ids: genresIds, + search_text: searchText + }); + + return series.map(series => SeriesModel( + series.series_id, + series.guid, + series.title, + series.description, + series.preview_image + )); + } + + public async getSeries(seriesId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + try { + const series: SeriesDbModel = await this.get(["api", provider.uniqueKey, "series", seriesId]); + + return SeriesModel( + series.series_id, + series.guid, + series.title, + series.description, + series.preview_image + ); + } catch (e) { + if (e instanceof HTTPError && e.status == 404) { + return null; + } + + throw e; + } + } + + public async getStartedSeries(): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const series: SeriesDbModel[] = await this.get(["api", provider.uniqueKey, "series", "started"]); + + return series.map(series => SeriesModel( + series.series_id, + series.guid, + series.title, + series.description, + series.preview_image + )); + } + + public async getSeriesByIds(seriesIds: number[]): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const series: SeriesDbModel[] = await this.get({ + path: ["api", provider.uniqueKey, "series"], + query: { + seriesIds: seriesIds + } + }); + + return series.map(series => SeriesModel( + series.series_id, + series.guid, + series.title, + series.description, + series.preview_image + )); + } +} + +export default { + key: SeriesService, + ctor: SeriesServiceImpl +} satisfies ServiceDeclaration; \ No newline at end of file diff --git a/app/src/services/client/utils/api.ts b/app/src/services/client/utils/api.ts index aa7fb43..f95165c 100644 --- a/app/src/services/client/utils/api.ts +++ b/app/src/services/client/utils/api.ts @@ -6,10 +6,10 @@ import * as http from "@utils/http"; export type PathParts = Array; export type QueryTypes = string | number | boolean; -export type PathParameter = PathParts[] | RouteBuilder; +export type PathParameter = PathParts | RouteBuilder; export interface RouteBuilder { - path: PathParts[]; + path: PathParts; query: Record; } @@ -25,9 +25,17 @@ export class ApiServiceBase { return await http.get(url).json(); } - protected async post(def: PathParameter, body: Body): Promise { + protected async post(def: PathParameter, body: Body): Promise { + let data: string; + + if (typeof body == "object") { + data = JSON.stringify(body); + } else { + data = body; + } + const url: string = this.buildURL(def); - return await http.post(url, body).json(); + return await http.post(url, data).json(); } private buildURL(def: PathParameter): string { diff --git a/app/tsconfig.json b/app/tsconfig.json index b9238b4..3d6fb82 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -23,7 +23,7 @@ "noFallthroughCasesInSwitch": true, "paths": { "@/*": ["./src/*"], - "@services/*": ["./src/services/standalone/*", "./src/services/shared/*"], + "@services/*": ["./src/services/standalone/*", "./src/services/client/*", "./src/services/shared/*"], "@views/*": ["./src/views/*"], "@models/*": ["./src/models/*"], "@icons/*": ["./src/icons/*"], From 29cf692b86ac25220bfeca8d4333c8a4c3461b55 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Sat, 25 Apr 2026 18:30:31 +0200 Subject: [PATCH 032/134] Update CD --- .github/workflows/CD.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 66615cc..de62bc8 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -30,6 +30,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 + working-directory: ./app with: node-version: 24 From 2d96cd7bc3e67f04530ae3482f87dd3ba8aea927 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Sat, 25 Apr 2026 18:32:46 +0200 Subject: [PATCH 033/134] Update CD --- .github/workflows/CD.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index de62bc8..9d2924a 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -30,9 +30,9 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 - working-directory: ./app with: node-version: 24 + cache-dependency-path: ./app/package-lock.json - name: Install dependencies run: npm ci From 60e2d25e2e4ba912425c17b229aed699b7c513f0 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Sat, 25 Apr 2026 20:09:51 +0200 Subject: [PATCH 034/134] Define ListService contract --- server/AniStream.Models/Models/ListModel.cs | 26 +++++++ .../Models/ListToSeriesModel.cs | 13 ++++ .../Contexts/MetadataDbContext.cs | 4 + .../Contracts/IListService.cs | 30 ++++++++ .../Services/ListServiceImpl.cs | 75 +++++++++++++++++++ 5 files changed, 148 insertions(+) create mode 100644 server/AniStream.Models/Models/ListModel.cs create mode 100644 server/AniStream.Models/Models/ListToSeriesModel.cs create mode 100644 server/AniStream.Services/Contracts/IListService.cs create mode 100644 server/AniStream.Services/Services/ListServiceImpl.cs diff --git a/server/AniStream.Models/Models/ListModel.cs b/server/AniStream.Models/Models/ListModel.cs new file mode 100644 index 0000000..1d17ca9 --- /dev/null +++ b/server/AniStream.Models/Models/ListModel.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("list")] +[PrimaryKey(nameof(ListId))] +public sealed class ListModel +{ + public int ListId { get; set; } + + public string Name { get; set; } + + public string TenantId { get; set; } + + public ListModel(string name, string tenantId) : this(0, name, tenantId) + { + } + + public ListModel(int listId, string name, string tenantId) + { + ListId = listId; + Name = name; + TenantId = tenantId; + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/ListToSeriesModel.cs b/server/AniStream.Models/Models/ListToSeriesModel.cs new file mode 100644 index 0000000..80a461c --- /dev/null +++ b/server/AniStream.Models/Models/ListToSeriesModel.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("list_to_series")] +[PrimaryKey(nameof(ListId), nameof(SeriesId))] +public sealed class ListToSeriesModel +{ + public int ListId { get; set; } + + public int SeriesId { get; set; } +} \ No newline at end of file diff --git a/server/AniStream.Services/Contexts/MetadataDbContext.cs b/server/AniStream.Services/Contexts/MetadataDbContext.cs index 7b30a61..88e0b9b 100644 --- a/server/AniStream.Services/Contexts/MetadataDbContext.cs +++ b/server/AniStream.Services/Contexts/MetadataDbContext.cs @@ -25,6 +25,10 @@ public MetadataDbContext(DbContextOptions options) : base(opt public DbSet Seasons { get; set; } public DbSet Episodes { get; set; } + + public DbSet Lists { get; set; } + + public DbSet ListsToSeries { get; set; } } public sealed class MetadataDbContextFactory : DbContextFactory diff --git a/server/AniStream.Services/Contracts/IListService.cs b/server/AniStream.Services/Contracts/IListService.cs new file mode 100644 index 0000000..9b604f1 --- /dev/null +++ b/server/AniStream.Services/Contracts/IListService.cs @@ -0,0 +1,30 @@ +using AniStream.Models; + +namespace AniStream.Contracts; + +public interface IListService +{ + public Task GetLists(); + + public Task GetList(int listId); + + public Task GetListsOfSeries(int seriesId); + + public Task CreateList(string name); + + public Task UpdateList(int listId, string name); + + public Task UpdateList(ListModel list, string name); + + public Task DeleteList(int listId); + + public Task DeleteList(ListModel list); + + public Task GetSeries(int listId); + + public Task AddSeriesToList(int listId, int seriesId); + + public Task RemoveSeriesFromList(int listId, int seriesId); + + public Task GetPreviewImages(int listId); +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/ListServiceImpl.cs b/server/AniStream.Services/Services/ListServiceImpl.cs new file mode 100644 index 0000000..bc662e6 --- /dev/null +++ b/server/AniStream.Services/Services/ListServiceImpl.cs @@ -0,0 +1,75 @@ +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Models; + +namespace AniStream.Services; + +public sealed class ListServiceImpl : IListService +{ + private MetadataDbContextFactory _dbFactory; + + public ListServiceImpl(MetadataDbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public async Task GetLists() + { + throw new NotImplementedException(); + } + + public Task GetList(int listId) + { + throw new NotImplementedException(); + } + + public Task GetListsOfSeries(int seriesId) + { + throw new NotImplementedException(); + } + + public Task CreateList(string name) + { + throw new NotImplementedException(); + } + + public Task UpdateList(int listId, string name) + { + throw new NotImplementedException(); + } + + public Task UpdateList(ListModel list, string name) + { + throw new NotImplementedException(); + } + + public Task DeleteList(int listId) + { + throw new NotImplementedException(); + } + + public Task DeleteList(ListModel list) + { + throw new NotImplementedException(); + } + + public Task GetSeries(int listId) + { + throw new NotImplementedException(); + } + + public Task AddSeriesToList(int listId, int seriesId) + { + throw new NotImplementedException(); + } + + public Task RemoveSeriesFromList(int listId, int seriesId) + { + throw new NotImplementedException(); + } + + public Task GetPreviewImages(int listId) + { + throw new NotImplementedException(); + } +} \ No newline at end of file From ab9b10988a248bf621718f029118867f726148a8 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 27 Apr 2026 12:37:54 +0200 Subject: [PATCH 035/134] Add unittests --- AniStream.sln.DotSettings.user | 11 +- AniStream.slnx | 1 + .../Utils/DbContextFactory.cs | 2 + .../Contexts/MetadataDbContext.cs | 4 +- .../Contexts/ProfileDbContext.cs | 2 +- .../Contracts/ISeasonService.cs | 2 +- .../Contracts/IUserService.cs | 12 ++ .../AniStream.Services/Services/AutoLoader.cs | 8 +- .../Services/EpisodeServiceImpl.cs | 8 +- .../Services/GenreServiceImpl.cs | 5 +- .../Services/ListServiceImpl.cs | 5 +- .../Services/SeasonServiceImpl.cs | 5 +- .../Services/SeriesServiceImpl.cs | 7 +- .../Services/UserServiceImpl.cs | 28 ++++- server/AniStream.Tests/AniStream.Tests.csproj | 26 +++++ server/AniStream.Tests/EpisodeServiceTests.cs | 90 ++++++++++++++ server/AniStream.Tests/GenreServiceTests.cs | 110 ++++++++++++++++++ server/AniStream.Tests/ListServiceTests.cs | 91 +++++++++++++++ .../AniStream.Tests/ProviderServiceTests.cs | 34 ++++++ server/AniStream.Tests/SeasonServiceTests.cs | 56 +++++++++ server/AniStream.Tests/SeriesServiceTests.cs | 85 ++++++++++++++ server/AniStream.Tests/UserServiceTests.cs | 70 +++++++++++ .../Utils/MetadataDbContextFactory.cs | 38 ++++++ .../Utils/ProfileDbContextFactory.cs | 38 ++++++ server/AniStream.Tests/Utils/TestBase.cs | 54 +++++++++ server/AniStream/Program.cs | 14 +-- .../AniStream/Services/CredentialsService.cs | 2 +- 27 files changed, 775 insertions(+), 33 deletions(-) create mode 100644 server/AniStream.Tests/AniStream.Tests.csproj create mode 100644 server/AniStream.Tests/EpisodeServiceTests.cs create mode 100644 server/AniStream.Tests/GenreServiceTests.cs create mode 100644 server/AniStream.Tests/ListServiceTests.cs create mode 100644 server/AniStream.Tests/ProviderServiceTests.cs create mode 100644 server/AniStream.Tests/SeasonServiceTests.cs create mode 100644 server/AniStream.Tests/SeriesServiceTests.cs create mode 100644 server/AniStream.Tests/UserServiceTests.cs create mode 100644 server/AniStream.Tests/Utils/MetadataDbContextFactory.cs create mode 100644 server/AniStream.Tests/Utils/ProfileDbContextFactory.cs create mode 100644 server/AniStream.Tests/Utils/TestBase.cs diff --git a/AniStream.sln.DotSettings.user b/AniStream.sln.DotSettings.user index d6f8bfa..c722bea 100644 --- a/AniStream.sln.DotSettings.user +++ b/AniStream.sln.DotSettings.user @@ -1,3 +1,12 @@  ForceIncluded - ForceIncluded \ No newline at end of file + ForceIncluded + /home/christoph/.cache/JetBrains/Rider2026.1/resharper-host/temp/Rider/vAny/CoverageData/_AniStream.1268352179/Snapshot/snapshot.utdcvr + <SessionState ContinuousTestingMode="0" IsActive="True" Name="Junie Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Project Location="/home/christoph/Documents/Workbench/rust/AniStream/server/AniStream.Tests" Presentation="&lt;server&gt;\&lt;AniStream.Tests&gt;" /> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="All tests from &lt;server&gt;\&lt;AniStream.Tests&gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Project Location="/home/christoph/Documents/Workbench/rust/AniStream/server/AniStream.Tests" Presentation="&lt;server&gt;\&lt;AniStream.Tests&gt;" /> +</SessionState> + + \ No newline at end of file diff --git a/AniStream.slnx b/AniStream.slnx index 925f581..69ac889 100644 --- a/AniStream.slnx +++ b/AniStream.slnx @@ -2,6 +2,7 @@ + diff --git a/server/AniStream.Models/Utils/DbContextFactory.cs b/server/AniStream.Models/Utils/DbContextFactory.cs index 4ce8c9b..b12fd81 100644 --- a/server/AniStream.Models/Utils/DbContextFactory.cs +++ b/server/AniStream.Models/Utils/DbContextFactory.cs @@ -36,6 +36,8 @@ protected DbContextFactory(string dbType, string migrationFolder, string databas } } + public abstract Task GetContext(); + protected void UseDbVariant(DbContextOptionsBuilder builder, string connectionString) { switch (_dbType) diff --git a/server/AniStream.Services/Contexts/MetadataDbContext.cs b/server/AniStream.Services/Contexts/MetadataDbContext.cs index 88e0b9b..04773ac 100644 --- a/server/AniStream.Services/Contexts/MetadataDbContext.cs +++ b/server/AniStream.Services/Contexts/MetadataDbContext.cs @@ -31,7 +31,7 @@ public MetadataDbContext(DbContextOptions options) : base(opt public DbSet ListsToSeries { get; set; } } -public sealed class MetadataDbContextFactory : DbContextFactory +internal sealed class MetadataDbContextFactory : DbContextFactory { private readonly string _connectionString; private readonly IProviderService _providerService; @@ -42,7 +42,7 @@ public MetadataDbContextFactory(string dbType, string migrationFolder, string co _providerService = providerService; } - public async Task GetContext() + public override async Task GetContext() { string providerName = _providerService.GetActiveProvider(); string actualConnString = String.Format(_connectionString, providerName); diff --git a/server/AniStream.Services/Contexts/ProfileDbContext.cs b/server/AniStream.Services/Contexts/ProfileDbContext.cs index 3bfd069..ab4a257 100644 --- a/server/AniStream.Services/Contexts/ProfileDbContext.cs +++ b/server/AniStream.Services/Contexts/ProfileDbContext.cs @@ -27,7 +27,7 @@ public ProfileDbContextFactory(string dbType, string migrationFolder, string con _connectionString = connectionString; } - public async Task GetContext() + public override async Task GetContext() { DbContextOptionsBuilder builder = new DbContextOptionsBuilder(); UseDbVariant(builder, _connectionString); diff --git a/server/AniStream.Services/Contracts/ISeasonService.cs b/server/AniStream.Services/Contracts/ISeasonService.cs index 2924b1c..19fdf87 100644 --- a/server/AniStream.Services/Contracts/ISeasonService.cs +++ b/server/AniStream.Services/Contracts/ISeasonService.cs @@ -8,5 +8,5 @@ public interface ISeasonService public Task GetSeasons(int seriesId); - public Task CreateSeason(int seriesId, int seasonNumer); + public Task CreateSeason(int seriesId, int seasonNumber); } \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/IUserService.cs b/server/AniStream.Services/Contracts/IUserService.cs index 24aba51..6ea2abf 100644 --- a/server/AniStream.Services/Contracts/IUserService.cs +++ b/server/AniStream.Services/Contracts/IUserService.cs @@ -4,6 +4,18 @@ namespace AniStream.Contracts; public interface IUserService { + public Task CreateProfile( + string uuid, + string name, + string backgroundColor, + string eye, + string mouth, + string theme, + string lang, + bool tosAccepted, + bool syncCatalog + ); + public Task GetActiveProfile(); public Task GetProfiles(); diff --git a/server/AniStream.Services/Services/AutoLoader.cs b/server/AniStream.Services/Services/AutoLoader.cs index b47ac7e..f5669ae 100644 --- a/server/AniStream.Services/Services/AutoLoader.cs +++ b/server/AniStream.Services/Services/AutoLoader.cs @@ -1,5 +1,6 @@ using AniStream.Contexts; using AniStream.Contracts; +using AniStream.Utils; using Microsoft.Extensions.DependencyInjection; namespace AniStream.Services; @@ -8,14 +9,15 @@ public sealed class AutoLoader { public static void LoadServices(IServiceCollection services, Options options) { - services.AddScoped(sp => + services.AddScoped>(_ => new ProfileDbContextFactory( options.DatabaseDriver, options.MigrationPath, options.DatabaseProfileConnectionString ) ); - services.AddScoped(sp => { + + services.AddScoped>(sp => { IProviderService providerService = sp.GetRequiredService(); return new MetadataDbContextFactory( @@ -29,9 +31,11 @@ public static void LoadServices(IServiceCollection services, Options options) services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } public struct Options diff --git a/server/AniStream.Services/Services/EpisodeServiceImpl.cs b/server/AniStream.Services/Services/EpisodeServiceImpl.cs index c3fe552..aa42957 100644 --- a/server/AniStream.Services/Services/EpisodeServiceImpl.cs +++ b/server/AniStream.Services/Services/EpisodeServiceImpl.cs @@ -1,17 +1,15 @@ -using System.Data.Common; -using System.Reflection.Metadata.Ecma335; using AniStream.Contexts; using AniStream.Contracts; using AniStream.Models; -using Microsoft.EntityFrameworkCore; +using AniStream.Utils; namespace AniStream.Services; class EpisodeServiceImpl : IEpisodeService { - private readonly MetadataDbContextFactory _dbFactory; + private readonly DbContextFactory _dbFactory; - public EpisodeServiceImpl(MetadataDbContextFactory dbFactory) + public EpisodeServiceImpl(DbContextFactory dbFactory) { _dbFactory = dbFactory; } diff --git a/server/AniStream.Services/Services/GenreServiceImpl.cs b/server/AniStream.Services/Services/GenreServiceImpl.cs index 3b40c15..20bb6d2 100644 --- a/server/AniStream.Services/Services/GenreServiceImpl.cs +++ b/server/AniStream.Services/Services/GenreServiceImpl.cs @@ -2,15 +2,16 @@ using AniStream.Contexts; using AniStream.Contracts; using AniStream.Models; +using AniStream.Utils; using Microsoft.EntityFrameworkCore; namespace AniStream.Services; public sealed class GenreServiceImpl : IGenreService { - private readonly MetadataDbContextFactory _dbFactory; + private readonly DbContextFactory _dbFactory; - public GenreServiceImpl(MetadataDbContextFactory dbFactory) + public GenreServiceImpl(DbContextFactory dbFactory) { _dbFactory = dbFactory; } diff --git a/server/AniStream.Services/Services/ListServiceImpl.cs b/server/AniStream.Services/Services/ListServiceImpl.cs index bc662e6..36c938c 100644 --- a/server/AniStream.Services/Services/ListServiceImpl.cs +++ b/server/AniStream.Services/Services/ListServiceImpl.cs @@ -1,14 +1,15 @@ using AniStream.Contexts; using AniStream.Contracts; using AniStream.Models; +using AniStream.Utils; namespace AniStream.Services; public sealed class ListServiceImpl : IListService { - private MetadataDbContextFactory _dbFactory; + private DbContextFactory _dbFactory; - public ListServiceImpl(MetadataDbContextFactory dbFactory) + public ListServiceImpl(DbContextFactory dbFactory) { _dbFactory = dbFactory; } diff --git a/server/AniStream.Services/Services/SeasonServiceImpl.cs b/server/AniStream.Services/Services/SeasonServiceImpl.cs index f52cc60..9a686e5 100644 --- a/server/AniStream.Services/Services/SeasonServiceImpl.cs +++ b/server/AniStream.Services/Services/SeasonServiceImpl.cs @@ -1,14 +1,15 @@ using AniStream.Contexts; using AniStream.Contracts; using AniStream.Models; +using AniStream.Utils; namespace AniStream.Services; public class SeasonServiceImpl : ISeasonService { - private MetadataDbContextFactory _dbFactory; + private DbContextFactory _dbFactory; - public SeasonServiceImpl(MetadataDbContextFactory dbFactory) + public SeasonServiceImpl(DbContextFactory dbFactory) { _dbFactory = dbFactory; } diff --git a/server/AniStream.Services/Services/SeriesServiceImpl.cs b/server/AniStream.Services/Services/SeriesServiceImpl.cs index 53f1adf..dfa0634 100644 --- a/server/AniStream.Services/Services/SeriesServiceImpl.cs +++ b/server/AniStream.Services/Services/SeriesServiceImpl.cs @@ -2,16 +2,15 @@ using AniStream.Contexts; using AniStream.Contracts; using AniStream.Models; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Internal; +using AniStream.Utils; namespace AniStream.Services; public sealed class SeriesSerivceImpl : ISeriesService { - private MetadataDbContextFactory _dbFactory; + private DbContextFactory _dbFactory; - public SeriesSerivceImpl(MetadataDbContextFactory dbFactory) + public SeriesSerivceImpl(DbContextFactory dbFactory) { _dbFactory = dbFactory; } diff --git a/server/AniStream.Services/Services/UserServiceImpl.cs b/server/AniStream.Services/Services/UserServiceImpl.cs index e8d8c25..9d4d4ee 100644 --- a/server/AniStream.Services/Services/UserServiceImpl.cs +++ b/server/AniStream.Services/Services/UserServiceImpl.cs @@ -1,20 +1,42 @@ using AniStream.Contexts; using AniStream.Contracts; using AniStream.Models; +using AniStream.Utils; using Microsoft.EntityFrameworkCore; namespace AniStream.Services; public class UserServiceImpl : IUserService { - private readonly ProfileDbContextFactory _dbFactory; + private readonly DbContextFactory _dbFactory; - - public UserServiceImpl(ProfileDbContextFactory dbFactory) + public UserServiceImpl(DbContextFactory dbFactory) { _dbFactory = dbFactory; } + public async Task CreateProfile( + string uuid, + string name, + string backgroundColor, + string eye, + string mouth, + string theme, + string lang, + bool tosAccepted, + bool syncCatalog + ) + { + await using ProfileDbContext db = await _dbFactory.GetContext(); + + ProfileModel profile = new ProfileModel(uuid, name, backgroundColor, eye, mouth, theme, lang, tosAccepted, syncCatalog); + + db.Profiles.Add(profile); + await db.SaveChangesAsync(); + + return profile; + } + public Task GetActiveProfile() { throw new NotImplementedException(); diff --git a/server/AniStream.Tests/AniStream.Tests.csproj b/server/AniStream.Tests/AniStream.Tests.csproj new file mode 100644 index 0000000..109da38 --- /dev/null +++ b/server/AniStream.Tests/AniStream.Tests.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/server/AniStream.Tests/EpisodeServiceTests.cs b/server/AniStream.Tests/EpisodeServiceTests.cs new file mode 100644 index 0000000..cf43353 --- /dev/null +++ b/server/AniStream.Tests/EpisodeServiceTests.cs @@ -0,0 +1,90 @@ +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Tests.Utils; + +namespace AniStream.Tests; + +public sealed class EpisodeServiceTests : TestBase +{ + private readonly ISeriesService _seriesService; + private readonly ISeasonService _seasonService; + private readonly IEpisodeService _episodeService; + + public EpisodeServiceTests() + { + _seriesService = GetService(); + _seasonService = GetService(); + _episodeService = GetService(); + } + + [Fact] + public async Task CreateEpisode() + { + SeriesModel series = await _seriesService.CreateSeries("a-series", "A Seris", "Desc", null); + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + + EpisodeModel episode = await _episodeService.CreateEpisode(season.SeasonId, 1, "DE", "EN", "Desc"); + + Assert.Equal(1, episode.EpisodeId); + Assert.Equal(season.SeasonId, episode.SeasonId); + Assert.Equal(1, episode.EpisodeNumber); + } + + [Fact] + public async Task GetEpisode() + { + SeriesModel series = await _seriesService.CreateSeries("a-series", "A Seris", "Desc", null); + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + EpisodeModel episode = await _episodeService.CreateEpisode(season.SeasonId, 1, "DE", "EN", "Desc"); + + EpisodeModel? loaded = await _episodeService.GetEpisode(episode.EpisodeId); + + Assert.NotNull(loaded); + Assert.Equal(episode.EpisodeId, loaded!.EpisodeId); + } + + [Fact] + public async Task GetEpisodes() + { + SeriesModel series = await _seriesService.CreateSeries("a-series", "A Seris", "Desc", null); + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + await _episodeService.CreateEpisode(season.SeasonId, 1, "DE", "EN", "Desc"); + + EpisodeModel[] episodes = await _episodeService.GetEpisodes(season.SeasonId); + + Assert.Single(episodes); + Assert.Equal(1, episodes[0].EpisodeNumber); + } + + [Fact] + public async Task UpdateEpisodeById() + { + SeriesModel series = await _seriesService.CreateSeries("a-series", "A Seris", "Desc", null); + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + EpisodeModel episode = await _episodeService.CreateEpisode(season.SeasonId, 1, "DE", "EN", "Desc"); + + EpisodeModel updated = await _episodeService.UpdateEpisode(episode.EpisodeId, episodeNumber: 2, englishTitle: "EN2"); + + Assert.Equal(2, updated.EpisodeNumber); + Assert.Equal("EN2", updated.EnglishTitle); + } + + [Fact] + public async Task UpdateEpisodeByModel() + { + SeriesModel series = await _seriesService.CreateSeries("a-series", "A Seris", "Desc", null); + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + EpisodeModel episode = await _episodeService.CreateEpisode(season.SeasonId, 1, "DE", "EN", "Desc"); + + EpisodeModel updated = await _episodeService.UpdateEpisode(episode, episodeNumber: 2, englishTitle: "EN2"); + + Assert.Equal(2, updated.EpisodeNumber); + Assert.Equal("EN2", updated.EnglishTitle); + } + + [Fact] + public async Task UpdateEpisodeByIdThrowsForUnknownEpisode() + { + await Assert.ThrowsAsync(() => _episodeService.UpdateEpisode(999, englishTitle: "X")); + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/GenreServiceTests.cs b/server/AniStream.Tests/GenreServiceTests.cs new file mode 100644 index 0000000..9c1a6aa --- /dev/null +++ b/server/AniStream.Tests/GenreServiceTests.cs @@ -0,0 +1,110 @@ +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Tests.Utils; + +namespace AniStream.Tests; + +public sealed class GenreServiceTests : TestBase +{ + private readonly IGenreService _genreService; + private readonly ISeriesService _seriesService; + + public GenreServiceTests() + { + _genreService = GetService(); + _seriesService = GetService(); + } + + [Fact] + public async Task CreateGenre() + { + GenreModel genre = await _genreService.CreateGenre("action"); + + Assert.Equal(1, genre.GenreId); + Assert.Equal("action", genre.Key); + } + + [Fact] + public async Task CreateGenreToSeries() + { + SeriesModel series = await _seriesService.CreateSeries("s-1", "Series", "Description", null); + GenreModel genre = await _genreService.CreateGenre("main"); + + await _genreService.CreateGenreToSeries(genre.GenreId, series.SeriesId, true); + + GenreModel? mainGenre = await _genreService.GetMainGenreOfSeries(series.SeriesId); + + Assert.NotNull(mainGenre); + Assert.Equal(genre.GenreId, mainGenre!.GenreId); + } + + [Fact] + public async Task GetGenres() + { + GenreModel genre = await _genreService.CreateGenre("action"); + + GenreModel[] genres = await _genreService.GetGenres(); + + Assert.Single(genres); + Assert.Equal(genre.GenreId, genres[0].GenreId); + } + + [Fact] + public async Task GetGenreById() + { + GenreModel genre = await _genreService.CreateGenre("action"); + + GenreModel? byId = await _genreService.GetGenre(genre.GenreId); + + Assert.NotNull(byId); + Assert.Equal(genre.GenreId, byId!.GenreId); + } + + [Fact] + public async Task GetGenreByKey() + { + GenreModel genre = await _genreService.CreateGenre("action"); + + GenreModel? byKey = await _genreService.GetGenre(genre.Key); + + Assert.NotNull(byKey); + Assert.Equal(genre.Key, byKey!.Key); + } + + [Fact] + public async Task GetMainGenreOfSeries() + { + await InsertSeriesWithGenres(); + + GenreModel? mainGenre = await _genreService.GetMainGenreOfSeries(1); + + Assert.NotNull(mainGenre); + Assert.Equal(1, mainGenre.GenreId); + } + + [Fact] + public async Task GetNonMainGenresOfSeries() + { + await InsertSeriesWithGenres(); + + GenreModel[] nonMainGenres = await _genreService.GetNonMainGenresOfSeries(1); + + Assert.Single(nonMainGenres); + Assert.Equal(2, nonMainGenres[0].GenreId); + } + + private async Task InsertSeriesWithGenres() + { + SeriesModel series = await _seriesService.CreateSeries("s-1", "Series", "Description", null); + GenreModel main = await _genreService.CreateGenre("main"); + GenreModel side = await _genreService.CreateGenre("side"); + + await _genreService.CreateGenreToSeries(main.GenreId, series.SeriesId, true); + await _genreService.CreateGenreToSeries(side.GenreId, series.SeriesId, false); + + Assert.Equal(1, series.SeriesId); + + Assert.Equal(1, main.GenreId); + Assert.Equal(2, side.GenreId); + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/ListServiceTests.cs b/server/AniStream.Tests/ListServiceTests.cs new file mode 100644 index 0000000..fc66036 --- /dev/null +++ b/server/AniStream.Tests/ListServiceTests.cs @@ -0,0 +1,91 @@ +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Tests.Utils; + +namespace AniStream.Tests; + +public sealed class ListServiceTests : TestBase +{ + private readonly IListService _listService; + + public ListServiceTests() + { + _listService = GetService(); + } + + [Fact] + public async Task GetLists_ThrowsNotImplementedException() + { + await Assert.ThrowsAsync(() => _listService.GetLists()); + } + + [Fact] + public async Task GetList_ThrowsNotImplementedException() + { + await Assert.ThrowsAsync(() => _listService.GetList(1)); + } + + [Fact] + public async Task GetListsOfSeries_ThrowsNotImplementedException() + { + await Assert.ThrowsAsync(() => _listService.GetListsOfSeries(1)); + } + + [Fact] + public async Task CreateList_ThrowsNotImplementedException() + { + await Assert.ThrowsAsync(() => _listService.CreateList("watchlist")); + } + + [Fact] + public async Task UpdateListById_ThrowsNotImplementedException() + { + await Assert.ThrowsAsync(() => _listService.UpdateList(1, "updated")); + } + + [Fact] + public async Task UpdateListByModel_ThrowsNotImplementedException() + { + ListModel list = new ListModel("watchlist", ""); + + await Assert.ThrowsAsync(() => _listService.UpdateList(list, "updated")); + } + + [Fact] + public async Task DeleteListById_ThrowsNotImplementedException() + { + await Assert.ThrowsAsync(() => _listService.DeleteList(1)); + } + + [Fact] + public async Task DeleteListByModel_ThrowsNotImplementedException() + { + ListModel list = new ListModel("watchlist", ""); + + await Assert.ThrowsAsync(() => _listService.DeleteList(list)); + } + + [Fact] + public async Task GetSeries_ThrowsNotImplementedException() + { + await Assert.ThrowsAsync(() => _listService.GetSeries(1)); + } + + [Fact] + public async Task AddSeriesToList_ThrowsNotImplementedException() + { + await Assert.ThrowsAsync(() => _listService.AddSeriesToList(1, 1)); + } + + [Fact] + public async Task RemoveSeriesFromList_ThrowsNotImplementedException() + { + await Assert.ThrowsAsync(() => _listService.RemoveSeriesFromList(1, 1)); + } + + [Fact] + public async Task GetPreviewImages_ThrowsNotImplementedException() + { + await Assert.ThrowsAsync(() => _listService.GetPreviewImages(1)); + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/ProviderServiceTests.cs b/server/AniStream.Tests/ProviderServiceTests.cs new file mode 100644 index 0000000..caf560f --- /dev/null +++ b/server/AniStream.Tests/ProviderServiceTests.cs @@ -0,0 +1,34 @@ +using AniStream.Contracts; +using AniStream.Tests.Utils; + +namespace AniStream.Tests; + +public sealed class ProviderServiceTests : TestBase +{ + private readonly IProviderService _providerService; + + public ProviderServiceTests() : base(false) + { + _providerService = GetService(); + } + + [Fact] + public void RejectsGettingNotSetProvider() + { + Assert.Throws(() => _providerService.GetActiveProvider()); + } + + [Fact] + public void SetActiveProvider() + { + _providerService.SetActiveProvider("aniworld"); + + Assert.Equal("aniworld", _providerService.GetActiveProvider()); + } + + [Fact] + public void RejectsSettingInvalidProvider() + { + Assert.Throws(() => _providerService.SetActiveProvider("unknown")); + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/SeasonServiceTests.cs b/server/AniStream.Tests/SeasonServiceTests.cs new file mode 100644 index 0000000..9c48383 --- /dev/null +++ b/server/AniStream.Tests/SeasonServiceTests.cs @@ -0,0 +1,56 @@ +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Tests.Utils; + +namespace AniStream.Tests; + +public sealed class SeasonServiceTests : TestBase +{ + private readonly ISeriesService _seriesService; + private readonly ISeasonService _seasonService; + + public SeasonServiceTests() + { + _seriesService = GetService(); + _seasonService = GetService(); + } + + [Fact] + public async Task CreateSeason() + { + SeriesModel series = await _seriesService.CreateSeries("g-ep", "Episodes", "Desc", null); + Assert.Equal(1, series.SeriesId); + + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + Assert.Equal(1, season.SeasonId); + Assert.Equal(series.SeriesId, season.SeriesId); + Assert.Equal(1, season.SeasonNumber); + } + + + [Fact] + public async Task GetSeasons() + { + SeriesModel series = await _seriesService.CreateSeries("g-ep", "Episodes", "Desc", null); + await _seasonService.CreateSeason(series.SeriesId, 1); + + SeasonModel[] bySeriesId = await _seasonService.GetSeasons(series.SeriesId); + + Assert.Single(bySeriesId); + Assert.Equal(1, bySeriesId[0].SeasonId); + Assert.Equal(1, bySeriesId[0].SeasonNumber); + } + + [Fact] + public async Task GetSeason() + { + SeriesModel series = await _seriesService.CreateSeries("g-ep", "Episodes", "Desc", null); + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + + SeasonModel? byId = await _seasonService.GetSeason(season.SeasonId); + + Assert.NotNull(byId); + Assert.Equal(season.SeasonId, byId.SeasonId); + Assert.Equal(season.SeasonNumber, byId.SeasonNumber); + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/SeriesServiceTests.cs b/server/AniStream.Tests/SeriesServiceTests.cs new file mode 100644 index 0000000..90d37d4 --- /dev/null +++ b/server/AniStream.Tests/SeriesServiceTests.cs @@ -0,0 +1,85 @@ +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Tests.Utils; + +namespace AniStream.Tests; + +public sealed class SeriesServiceTests : TestBase +{ + private readonly ISeriesService _seriesService; + private readonly IGenreService _genreService; + + public SeriesServiceTests() + { + _seriesService = GetService(); + _genreService = GetService(); + } + + [Fact] + public async Task CreateSeries() + { + SeriesModel series = await _seriesService.CreateSeries("a-series", "A Seris", "Desc", null); + Assert.Equal(1, series.SeriesId); + Assert.Equal("A Seris", series.Title); + Assert.Equal("Desc", series.Description); + Assert.Null(series.PreviewImage); + } + + [Fact] + public async Task GetSeriesById() + { + SeriesModel created = await _seriesService.CreateSeries("g-a", "Alpha Show", "", null); + + SeriesModel? series = await _seriesService.GetSeries(created.SeriesId); + + Assert.NotNull(series); + Assert.Equal(created.SeriesId, series!.SeriesId); + } + + [Fact] + public async Task GetSeriesByGuid() + { + SeriesModel created = await _seriesService.CreateSeries("g-a", "Alpha Show", "", null); + + SeriesModel? series = await _seriesService.GetSeries(created.Guid); + + Assert.NotNull(series); + Assert.Equal(created.SeriesId, series!.SeriesId); + } + + [Fact] + public async Task GetSeriesByIds() + { + SeriesModel alpha = await _seriesService.CreateSeries("g-a", "Alpha Show", "", null); + await _seriesService.CreateSeries("g-b", "Beta Show", "", null); + SeriesModel gamma = await _seriesService.CreateSeries("g-c", "Gamma", "", null); + + SeriesModel[] series = await _seriesService.GetSeriesByIds([alpha.SeriesId, gamma.SeriesId]); + + Assert.Equal(["Alpha Show", "Gamma"], series.OrderBy(s => s.Title).Select(s => s.Title).ToArray()); + } + + [Fact] + public async Task GetSeriesChunk() + { + SeriesModel alpha = await _seriesService.CreateSeries("g-a", "Alpha Show", "", null); + await _seriesService.CreateSeries("g-b", "Beta Show", "", null); + SeriesModel gamma = await _seriesService.CreateSeries("g-c", "Gamma", "", null); + GenreModel adventure = await _genreService.CreateGenre("adventure"); + await _genreService.CreateGenreToSeries(adventure.GenreId, alpha.SeriesId, true); + await _genreService.CreateGenreToSeries(adventure.GenreId, gamma.SeriesId, false); + + SeriesModel[] filtered = await _seriesService.GetSeriesChunk(0, 10, "a", [adventure.GenreId]); + SeriesModel[] paged = await _seriesService.GetSeriesChunk(1, 1, null, null); + + Assert.Equal(["Alpha Show", "Gamma"], filtered.Select(s => s.Title).ToArray()); + Assert.Single(paged); + Assert.Equal("Beta Show", paged[0].Title); + } + + [Fact] + public async Task GetStartedSeries_ThrowsNotImplementedException() + { + await Assert.ThrowsAsync(() => _seriesService.GetStartedSeries()); + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/UserServiceTests.cs b/server/AniStream.Tests/UserServiceTests.cs new file mode 100644 index 0000000..d0a0ad0 --- /dev/null +++ b/server/AniStream.Tests/UserServiceTests.cs @@ -0,0 +1,70 @@ +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Tests.Utils; + +namespace AniStream.Tests; + +public sealed class UserServiceTests : TestBase +{ + private readonly IUserService _userService; + + public UserServiceTests() + { + _userService = GetService(); + } + + [Fact] + public async Task CreateProfile() + { + Guid johnGuid = Guid.NewGuid(); + ProfileModel john = await _userService.CreateProfile(johnGuid.ToString(), "john", "fff", "eye-1", "mouth-1", "dark", "en", true, false); + + Assert.Equal(1, john.ProfileId); + Assert.Equal("john", john.Name); + } + + [Fact] + public async Task GetProfiles() + { + Guid johnGuid = Guid.NewGuid(); + Guid janeGuid = Guid.NewGuid(); + await _userService.CreateProfile(johnGuid.ToString(), "john", "fff", "eye-1", "mouth-1", "dark", "en", true, false); + await _userService.CreateProfile(janeGuid.ToString(), "jane", "000", "eye-2", "mouth-2", "light", "de", true, true); + + ProfileModel[] profiles = await _userService.GetProfiles(); + + Assert.Equal(2, profiles.Length); + } + + [Fact] + public async Task GetProfileByName() + { + Guid johnGuid = Guid.NewGuid(); + Guid janeGuid = Guid.NewGuid(); + await _userService.CreateProfile(johnGuid.ToString(), "john", "fff", "eye-1", "mouth-1", "dark", "en", true, false); + await _userService.CreateProfile(janeGuid.ToString(), "jane", "000", "eye-2", "mouth-2", "light", "de", true, true); + + ProfileModel? profileByName = await _userService.GetProfile("jane"); + + Assert.NotNull(profileByName); + Assert.Equal("jane", profileByName!.Name); + } + + [Fact] + public async Task GetProfileById() + { + Guid johnGuid = Guid.NewGuid(); + ProfileModel john = await _userService.CreateProfile(johnGuid.ToString(), "john", "fff", "eye-1", "mouth-1", "dark", "en", true, false); + + ProfileModel? profileById = await _userService.GetProfile(john.ProfileId); + + Assert.NotNull(profileById); + Assert.Equal("john", profileById!.Name); + } + + [Fact] + public async Task GetActiveProfile_ThrowsNotImplementedException() + { + await Assert.ThrowsAsync(() => _userService.GetActiveProfile()); + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/Utils/MetadataDbContextFactory.cs b/server/AniStream.Tests/Utils/MetadataDbContextFactory.cs new file mode 100644 index 0000000..e7202c2 --- /dev/null +++ b/server/AniStream.Tests/Utils/MetadataDbContextFactory.cs @@ -0,0 +1,38 @@ +using AniStream.Contexts; +using AniStream.Utils; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Tests.Utils; + +internal class MetadataDbContextFactory : DbContextFactory +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; + private bool _migrated; + + public MetadataDbContextFactory(string migrationFolder) : base("sqlite", migrationFolder, "metadata") + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + DbContextOptionsBuilder builder = new DbContextOptionsBuilder(); + builder.UseSqlite(_connection); + + _options = builder.Options; + _migrated = false; + } + + public override async Task GetContext() + { + MetadataDbContext context = new MetadataDbContext(_options); + + if (!_migrated) + { + await MigrateDatabaseIfRequired(context); + _migrated = true; + } + + return context; + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/Utils/ProfileDbContextFactory.cs b/server/AniStream.Tests/Utils/ProfileDbContextFactory.cs new file mode 100644 index 0000000..94c1a1f --- /dev/null +++ b/server/AniStream.Tests/Utils/ProfileDbContextFactory.cs @@ -0,0 +1,38 @@ +using AniStream.Contexts; +using AniStream.Utils; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Tests.Utils; + +internal class ProfileDbContextFactory : DbContextFactory +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; + private bool _migrated; + + public ProfileDbContextFactory(string migrationFolder) : base("sqlite", migrationFolder, "profile") + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + DbContextOptionsBuilder builder = new DbContextOptionsBuilder(); + builder.UseSqlite(_connection); + + _options = builder.Options; + _migrated = false; + } + + public override async Task GetContext() + { + ProfileDbContext context = new ProfileDbContext(_options); + + if (!_migrated) + { + await MigrateDatabaseIfRequired(context); + _migrated = true; + } + + return context; + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/Utils/TestBase.cs b/server/AniStream.Tests/Utils/TestBase.cs new file mode 100644 index 0000000..a391e85 --- /dev/null +++ b/server/AniStream.Tests/Utils/TestBase.cs @@ -0,0 +1,54 @@ +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Services; +using AniStream.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace AniStream.Tests.Utils; + +public abstract class TestBase : IDisposable +{ + private readonly ServiceCollection _services; + private readonly ServiceProvider _provider; + + private readonly IServiceScope _scope; + + public TestBase(bool setProvider = true) + { + // TODO resolve dynamically + const string MIGRATION_PATH = "/home/christoph/Documents/Workbench/rust/AniStream/migration"; + _services = new ServiceCollection(); + + AutoLoader.LoadServices(_services, new AutoLoader.Options + { + DatabaseDriver = "sqlite", + MigrationPath = MIGRATION_PATH, + DatabaseMetadataConnectionString = "DataSource=:memory:", + DatabaseProfileConnectionString = "DataSource=:memory:" + }); + + // Overwrite DB handlers so that each Fact uses one DB-Connection + _services.AddScoped>(_ => new MetadataDbContextFactory(MIGRATION_PATH)); + _services.AddScoped>(_ => new ProfileDbContextFactory(MIGRATION_PATH)); + + // TODO inject credentials service mock + _provider = _services.BuildServiceProvider(); + _scope = _provider.CreateScope(); + + if (setProvider) + { + IProviderService providerService = GetService(); + providerService.SetActiveProvider("sto"); + } + } + + protected T GetService() where T : class + { + return _scope.ServiceProvider.GetRequiredService(); + } + + public void Dispose() + { + _scope.Dispose(); + } +} \ No newline at end of file diff --git a/server/AniStream/Program.cs b/server/AniStream/Program.cs index c83d725..be4f4d1 100644 --- a/server/AniStream/Program.cs +++ b/server/AniStream/Program.cs @@ -1,14 +1,14 @@ -using Scalar.AspNetCore; -using AniStream.API.Utils; -using Microsoft.AspNetCore.Authentication.Cookies; using AniStream.API.Controllers; -using AniStream.Contracts; +using AniStream.API.Middelware; using AniStream.API.Serivces; +using AniStream.API.Utils; +using AniStream.Contracts; using AniStream.Services; -using Microsoft.OpenApi.Models; -using Microsoft.AspNetCore.Mvc; -using AniStream.API.Middelware; using AniStream.Utils; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; +using Microsoft.OpenApi.Models; +using Scalar.AspNetCore; namespace AniStream.API; diff --git a/server/AniStream/Services/CredentialsService.cs b/server/AniStream/Services/CredentialsService.cs index 07a58ce..10ad507 100644 --- a/server/AniStream/Services/CredentialsService.cs +++ b/server/AniStream/Services/CredentialsService.cs @@ -1,5 +1,5 @@ -using AniStream.Models; using AniStream.Contracts; +using AniStream.Models; namespace AniStream.API.Serivces; From 2b267f44a5e50f2aba53977e72c6d036b63c43b6 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 27 Apr 2026 13:21:42 +0200 Subject: [PATCH 036/134] Set cache path --- .github/workflows/CI.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 14bd453..f4669d8 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -20,6 +20,7 @@ jobs: with: node-version: '24' cache: 'npm' + cache-dependency-path: 'app/package-lock.json' - name: Install dependencies run: | From 7fe64528fe305ee1866dbf7f615a544c90f5f986 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 27 Apr 2026 13:22:46 +0200 Subject: [PATCH 037/134] Add Client build to test --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index f4669d8..f07b751 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - target: ["standalone"] + target: ["standalone", "client"] steps: - uses: actions/checkout@v4 From 7274c1cbef46e860c96d00e77ef5e6725f4ef143 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 27 Apr 2026 13:42:27 +0200 Subject: [PATCH 038/134] Run unittest in CI --- .github/workflows/CI.yml | 20 ++++++++++++++++++- server/AniStream.Tests/AniStream.Tests.csproj | 7 +++++++ server/AniStream.Tests/Utils/TestBase.cs | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index f07b751..cb44b76 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -7,7 +7,8 @@ on: branches: [ main, master ] jobs: - build: + build_targets: + name: Build ${{ matrix.target }} runs-on: ubuntu-latest strategy: matrix: @@ -35,3 +36,20 @@ jobs: cd app export APPLICATION_TARGET="${{ matrix.target }}" npm run build + + server_unittest: + name: Server Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup ASP.NET Core + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '9.0.x' + + - name: Build Solution + run: dotnet build ./AniStream.slnx + + - name: Run Unit tests + run: dotnet test ./AniStream.slnx --no-build \ No newline at end of file diff --git a/server/AniStream.Tests/AniStream.Tests.csproj b/server/AniStream.Tests/AniStream.Tests.csproj index 109da38..f6f8cb0 100644 --- a/server/AniStream.Tests/AniStream.Tests.csproj +++ b/server/AniStream.Tests/AniStream.Tests.csproj @@ -19,6 +19,13 @@ + + + migration/%(RecursiveDir)/%(Filename)%(Extension) + PreserveNewest + + + diff --git a/server/AniStream.Tests/Utils/TestBase.cs b/server/AniStream.Tests/Utils/TestBase.cs index a391e85..d6b6cc5 100644 --- a/server/AniStream.Tests/Utils/TestBase.cs +++ b/server/AniStream.Tests/Utils/TestBase.cs @@ -16,7 +16,7 @@ public abstract class TestBase : IDisposable public TestBase(bool setProvider = true) { // TODO resolve dynamically - const string MIGRATION_PATH = "/home/christoph/Documents/Workbench/rust/AniStream/migration"; + const string MIGRATION_PATH = "./migration"; _services = new ServiceCollection(); AutoLoader.LoadServices(_services, new AutoLoader.Options From 78d2ad63134cccca92263ede735f40f8ced78d5d Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 27 Apr 2026 15:19:00 +0200 Subject: [PATCH 039/134] Implement basic testing suite --- app/package-lock.json | 389 +++++++++++++++++++++++++++---- app/package.json | 2 + app/tests/services/genre.test.ts | 16 ++ app/tests/suite.ts | 5 + app/tests/utils/client.ts | 15 ++ app/tests/utils/harness.ts | 82 +++++++ app/tests/utils/standalone.ts | 15 ++ app/tsconfig.json | 6 +- app/vite.config.ts | 3 +- 9 files changed, 486 insertions(+), 47 deletions(-) create mode 100644 app/tests/services/genre.test.ts create mode 100644 app/tests/suite.ts create mode 100644 app/tests/utils/client.ts create mode 100644 app/tests/utils/harness.ts create mode 100644 app/tests/utils/standalone.ts diff --git a/app/package-lock.json b/app/package-lock.json index b0e3181..f34b912 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "anistream", - "version": "1.1.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "anistream", - "version": "1.1.0", + "version": "1.3.0", "dependencies": { "@dicebear/collection": "^9.4.2", "@dicebear/core": "^9.4.2", @@ -19,6 +19,7 @@ "@tauri-apps/plugin-sql": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.0", "hls.js": "^1.6.15", + "vitest": "^4.1.5", "vue": "^3.5.13", "vue-mvvm": "^0.7.0", "vue-router": "^5.0.4" @@ -531,7 +532,6 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -543,7 +543,6 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -554,7 +553,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -610,7 +608,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -629,7 +626,6 @@ "version": "0.122.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -642,7 +638,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -659,7 +654,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -676,7 +670,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -693,7 +686,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -710,7 +702,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -727,7 +718,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -747,7 +737,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -767,7 +756,6 @@ "cpu": [ "ppc64" ], - "dev": true, "libc": [ "glibc" ], @@ -787,7 +775,6 @@ "cpu": [ "s390x" ], - "dev": true, "libc": [ "glibc" ], @@ -807,7 +794,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -827,7 +813,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -847,7 +832,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -864,7 +848,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -881,7 +864,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -898,7 +880,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -912,7 +893,12 @@ "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", - "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, "node_modules/@tailwindcss/node": { @@ -1493,13 +1479,34 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1510,7 +1517,7 @@ "version": "25.2.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz", "integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -1547,6 +1554,121 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.15", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", @@ -1785,6 +1907,15 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-kit": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", @@ -1843,6 +1974,15 @@ "balanced-match": "^1.0.0" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -1864,6 +2004,12 @@ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1891,7 +2037,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1923,12 +2068,27 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "license": "MIT" + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -1956,7 +2116,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2000,7 +2159,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -2034,7 +2193,6 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -2067,7 +2225,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2088,7 +2245,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2109,7 +2265,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2130,7 +2285,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2151,7 +2305,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2172,7 +2325,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -2196,7 +2348,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -2220,7 +2371,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -2244,7 +2394,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -2268,7 +2417,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2289,7 +2437,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2413,6 +2560,16 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -2522,7 +2679,6 @@ "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", - "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.122.0", @@ -2558,6 +2714,12 @@ "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2567,6 +2729,18 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -2588,6 +2762,21 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -2604,11 +2793,19 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD", "optional": true }, @@ -2636,7 +2833,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unplugin": { @@ -2673,7 +2870,6 @@ "version": "8.0.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.5.tgz", "integrity": "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==", - "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", @@ -2747,6 +2943,95 @@ } } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -2853,6 +3138,22 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yaml": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", diff --git a/app/package.json b/app/package.json index b177b11..3b4e5e6 100644 --- a/app/package.json +++ b/app/package.json @@ -4,6 +4,7 @@ "version": "1.3.0", "type": "module", "scripts": { + "test": "vitest run", "dev": "vite", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", @@ -21,6 +22,7 @@ "@tauri-apps/plugin-sql": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.0", "hls.js": "^1.6.15", + "vitest": "^4.1.5", "vue": "^3.5.13", "vue-mvvm": "^0.7.0", "vue-router": "^5.0.4" diff --git a/app/tests/services/genre.test.ts b/app/tests/services/genre.test.ts new file mode 100644 index 0000000..d132022 --- /dev/null +++ b/app/tests/services/genre.test.ts @@ -0,0 +1,16 @@ +import {TestBase, TestDefinition} from "@test/suite"; + +class GenreTests extends TestBase { + private async test1() { + console.log(this); + } + + public getTests(): TestDefinition[] { + return [ + ["Test 1", this.test1] + ]; + } + +} + +TestBase.register(GenreTests); \ No newline at end of file diff --git a/app/tests/suite.ts b/app/tests/suite.ts new file mode 100644 index 0000000..5fbc0a9 --- /dev/null +++ b/app/tests/suite.ts @@ -0,0 +1,5 @@ +export * from "@test/utils/harness"; + +import "@test/utils/harness"; +import "@test/utils/client"; +import "@test/utils/standalone"; \ No newline at end of file diff --git a/app/tests/utils/client.ts b/app/tests/utils/client.ts new file mode 100644 index 0000000..dc82128 --- /dev/null +++ b/app/tests/utils/client.ts @@ -0,0 +1,15 @@ +import {ServiceTestHarness, TestBase} from "@test/utils/harness"; + +class ClientTestHarness implements ServiceTestHarness { + setUp(): void | Promise { + } + + tearDown(): void | Promise { + } + + getService(_key: unknown): T { + throw new Error("Method not implemented."); + } +} + +TestBase.registerHarness("client", () => new ClientTestHarness()); \ No newline at end of file diff --git a/app/tests/utils/harness.ts b/app/tests/utils/harness.ts new file mode 100644 index 0000000..48a990d --- /dev/null +++ b/app/tests/utils/harness.ts @@ -0,0 +1,82 @@ +import {describe, test} from "vitest"; +import {syncio} from "vue-mvvm"; + +export interface ServiceTestHarness { + setUp(): void | Promise; + + tearDown(): void | Promise; + + getService(key: unknown): T; +} + +export type TestMethod = (this: TestBase) => Promise | void; + +export type TestDefinition = [ + name: string, + method: TestMethod +]; + +export abstract class TestBase { + public static harnessFactory: (() => ServiceTestHarness) | null = null; + + private harness: ServiceTestHarness; + + public constructor() { + if (!TestBase.harnessFactory) { + throw `No harness factory defined for target '${TestBase.applicationTarget}'`; + } + + this.harness = TestBase.harnessFactory(); + } + + public async setUp(): Promise { + await syncio.ensureSync(this.harness.setUp()); + } + + public async tearDown(): Promise { + await syncio.ensureSync(this.harness.tearDown()); + } + + public abstract getTests(): TestDefinition[]; + + protected getService(key: unknown): T { + return this.harness.getService(key); + } + + public static register(testType: new () => T): void { + const discoveryInstance = new testType(); + + describe(`${testType.name}`, () => { + for (const [name, method] of discoveryInstance.getTests()) { + test(name, async () => { + const testInstance = new testType(); + + const boundMethod = method.bind(testInstance); + + try { + await testInstance.setUp(); + await boundMethod(); + } finally { + await testInstance.tearDown(); + } + }); + } + }); + } + + public static registerHarness(target: string, factory: () => ServiceTestHarness): void { + if (target != TestBase.applicationTarget) { + return; + } + TestBase.harnessFactory = factory; + } + + private static get applicationTarget() { + if (!process.env.APPLICATION_TARGET) { + throw "APPLICATION_TARGET is not defined"; + } + + return process.env.APPLICATION_TARGET; + } +} + diff --git a/app/tests/utils/standalone.ts b/app/tests/utils/standalone.ts new file mode 100644 index 0000000..f5856db --- /dev/null +++ b/app/tests/utils/standalone.ts @@ -0,0 +1,15 @@ +import {ServiceTestHarness, TestBase} from "@test/utils/harness"; + +class StandaloneTestHarness implements ServiceTestHarness { + setUp(): void | Promise { + } + + tearDown(): void | Promise { + } + + getService(_key: unknown): T { + throw new Error("Method not implemented."); + } +} + +TestBase.registerHarness("standalone", () => new StandaloneTestHarness()); \ No newline at end of file diff --git a/app/tsconfig.json b/app/tsconfig.json index 3d6fb82..28502df 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -33,14 +33,16 @@ "@providers/*": ["./src/providers/*"], "@sources": ["./src/sources/index.ts"], "@sources/*": ["./src/sources/*"], - "@contracts/*": ["./src/contracts/*"] + "@contracts/*": ["./src/contracts/*"], + "@test/*": ["./tests/*"] } }, "include": [ "src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", - "src/**/*.vue" + "src/**/*.vue", + "tests/**/*.ts" ], "references": [ { diff --git a/app/vite.config.ts b/app/vite.config.ts index c600888..7fe70c1 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -98,7 +98,8 @@ export default defineConfig(async (): Promise => { "@langs": path.join(__dirname, "src", "langs"), "@providers": path.join(__dirname, "src", "providers"), "@sources": path.join(__dirname, "src", "sources"), - "@contracts": path.join(__dirname, "src", "contracts") + "@contracts": path.join(__dirname, "src", "contracts"), + "@test": path.join(__dirname, "tests") } }, From 415e135fd77b0ae0a08d5d68a0120895ef02611e Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 27 Apr 2026 17:09:35 +0200 Subject: [PATCH 040/134] Implement List service and controller --- .../Models/ListToSeriesModel.cs | 6 + .../Contracts/ICredentialService.cs | 6 +- .../Contracts/IListService.cs | 8 + .../Services/EpisodeServiceImpl.cs | 8 +- .../Services/ListServiceImpl.cs | 175 +++++++++++++++--- .../Controllers/CredentialsController.cs | 5 +- .../AniStream/Controllers/ListController.cs | 135 ++++++++++++++ server/AniStream/DTO/ListModel.cs | 30 +++ .../AniStream/Services/CredentialsService.cs | 21 ++- 9 files changed, 360 insertions(+), 34 deletions(-) create mode 100644 server/AniStream/Controllers/ListController.cs create mode 100644 server/AniStream/DTO/ListModel.cs diff --git a/server/AniStream.Models/Models/ListToSeriesModel.cs b/server/AniStream.Models/Models/ListToSeriesModel.cs index 80a461c..1271a16 100644 --- a/server/AniStream.Models/Models/ListToSeriesModel.cs +++ b/server/AniStream.Models/Models/ListToSeriesModel.cs @@ -10,4 +10,10 @@ public sealed class ListToSeriesModel public int ListId { get; set; } public int SeriesId { get; set; } + + public ListToSeriesModel(int listId, int seriesId) + { + ListId = listId; + SeriesId = seriesId; + } } \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/ICredentialService.cs b/server/AniStream.Services/Contracts/ICredentialService.cs index 61474c6..b6a58ee 100644 --- a/server/AniStream.Services/Contracts/ICredentialService.cs +++ b/server/AniStream.Services/Contracts/ICredentialService.cs @@ -1,6 +1,10 @@ +using AniStream.Models; + namespace AniStream.Contracts; public interface ICredentialsService { - public Task ValidateCredentials(string username, string password); + public Task ValidateCredentials(string username, string password); + + public Task GetCurrentUuid(); } \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/IListService.cs b/server/AniStream.Services/Contracts/IListService.cs index 9b604f1..064cc26 100644 --- a/server/AniStream.Services/Contracts/IListService.cs +++ b/server/AniStream.Services/Contracts/IListService.cs @@ -22,9 +22,17 @@ public interface IListService public Task GetSeries(int listId); + public Task GetSeries(ListModel list); + public Task AddSeriesToList(int listId, int seriesId); + public Task AddSeriesToList(ListModel list, int seriesId); + public Task RemoveSeriesFromList(int listId, int seriesId); + public Task RemoveSeriesFromList(ListModel list, int seriesId); + public Task GetPreviewImages(int listId); + + public Task GetPreviewImages(ListModel list); } \ No newline at end of file diff --git a/server/AniStream.Services/Services/EpisodeServiceImpl.cs b/server/AniStream.Services/Services/EpisodeServiceImpl.cs index aa42957..46810a2 100644 --- a/server/AniStream.Services/Services/EpisodeServiceImpl.cs +++ b/server/AniStream.Services/Services/EpisodeServiceImpl.cs @@ -31,7 +31,7 @@ public async Task CreateEpisode(int seasonId, int episodeNumber, s await using MetadataDbContext db = await _dbFactory.GetContext(); IQueryable query = from episode in db.Episodes where episode.EpisodeId == episodeId select episode; - + return query.FirstOrDefault(); } @@ -84,18 +84,22 @@ public async Task UpdateEpisode( { episode.SeasonId = (int)seasonId; } + if (episodeNumber is not null) { episode.EpisodeNumber = (int)episodeNumber; } + if (germanTitle is not null) { episode.GermanTitle = germanTitle; } + if (englishTitle is not null) { episode.EnglishTitle = englishTitle; } + if (description is not null) { episode.Description = description; @@ -106,6 +110,4 @@ public async Task UpdateEpisode( return episode; } - - } \ No newline at end of file diff --git a/server/AniStream.Services/Services/ListServiceImpl.cs b/server/AniStream.Services/Services/ListServiceImpl.cs index 36c938c..5bbfe7a 100644 --- a/server/AniStream.Services/Services/ListServiceImpl.cs +++ b/server/AniStream.Services/Services/ListServiceImpl.cs @@ -2,75 +2,202 @@ using AniStream.Contracts; using AniStream.Models; using AniStream.Utils; +using Microsoft.EntityFrameworkCore; namespace AniStream.Services; public sealed class ListServiceImpl : IListService { private DbContextFactory _dbFactory; + private readonly ICredentialsService _credentialsService; - public ListServiceImpl(DbContextFactory dbFactory) + public ListServiceImpl(DbContextFactory dbFactory, ICredentialsService credentialsService) { _dbFactory = dbFactory; + _credentialsService = credentialsService; } public async Task GetLists() { - throw new NotImplementedException(); + string tenantId = await _credentialsService.GetCurrentUuid(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from list in db.Lists where list.TenantId == tenantId select list; + + return await query.ToArrayAsync(); } - public Task GetList(int listId) + public async Task GetList(int listId) { - throw new NotImplementedException(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from list in db.Lists where list.ListId == listId select list; + + return await query.FirstOrDefaultAsync(); } - public Task GetListsOfSeries(int seriesId) + public async Task GetListsOfSeries(int seriesId) { - throw new NotImplementedException(); + string tenantId = await _credentialsService.GetCurrentUuid(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from listToSeries in db.ListsToSeries + join list in db.Lists on listToSeries.ListId equals list.ListId + where listToSeries.SeriesId == seriesId && list.TenantId == tenantId + select list; + + return await query.ToArrayAsync(); } - public Task CreateList(string name) + public async Task CreateList(string name) { - throw new NotImplementedException(); + string tenantId = await _credentialsService.GetCurrentUuid(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + ListModel list = new ListModel(name, tenantId); + + db.Lists.Add(list); + await db.SaveChangesAsync(); + + return list; } - public Task UpdateList(int listId, string name) + public async Task UpdateList(int listId, string name) { - throw new NotImplementedException(); + ListModel? list = await GetList(listId); + if (list is null) + { + throw new Exception($"List with ID '{listId}' not found"); + } + + return await UpdateList(list, name); } - public Task UpdateList(ListModel list, string name) + public async Task UpdateList(ListModel list, string name) { - throw new NotImplementedException(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + list.Name = name; + + db.Lists.Update(list); + await db.SaveChangesAsync(); + + return list; } - public Task DeleteList(int listId) + public async Task DeleteList(int listId) { - throw new NotImplementedException(); + ListModel? list = await GetList(listId); + if (list is null) + { + throw new Exception($"List with ID '{listId}' not found"); + } + + await DeleteList(list); } - public Task DeleteList(ListModel list) + public async Task DeleteList(ListModel list) { - throw new NotImplementedException(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + db.Lists.Remove(list); + await db.SaveChangesAsync(); } - public Task GetSeries(int listId) + public async Task GetSeries(int listId) { - throw new NotImplementedException(); + ListModel? list = await GetList(listId); + + if (list is null) + { + throw new Exception($"List with ID '{listId}' not found"); + } + + return await GetSeries(list); } - public Task AddSeriesToList(int listId, int seriesId) + public async Task GetSeries(ListModel list) { - throw new NotImplementedException(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from listToSeries in db.ListsToSeries + join series in db.Series on listToSeries.SeriesId equals series.SeriesId + where listToSeries.ListId == list.ListId + select series; + + return await query.ToArrayAsync(); } - public Task RemoveSeriesFromList(int listId, int seriesId) + public async Task AddSeriesToList(int listId, int seriesId) { - throw new NotImplementedException(); + ListModel? list = await GetList(listId); + if (list is null) + { + throw new Exception($"List with ID '{listId}' not found"); + } + + await AddSeriesToList(list, seriesId); } - public Task GetPreviewImages(int listId) + public async Task AddSeriesToList(ListModel list, int seriesId) { - throw new NotImplementedException(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + ListToSeriesModel listToSeries = new ListToSeriesModel(list.ListId, seriesId); + + db.ListsToSeries.Add(listToSeries); + await db.SaveChangesAsync(); + } + + public async Task RemoveSeriesFromList(int listId, int seriesId) + { + ListModel? list = await GetList(listId); + if (list is null) + { + throw new Exception($"List with ID '{listId}' not found"); + } + + await RemoveSeriesFromList(list, seriesId); + } + + public async Task RemoveSeriesFromList(ListModel list, int seriesId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from lts in db.ListsToSeries + where lts.ListId == list.ListId && lts.SeriesId == seriesId + select lts; + + ListToSeriesModel? listToSeries = await query.FirstOrDefaultAsync(); + if (listToSeries is null) + { + return; + } + + db.ListsToSeries.Remove(listToSeries); + await db.SaveChangesAsync(); + } + + public async Task GetPreviewImages(int listId) + { + ListModel? list = await GetList(listId); + if (list is null) + { + throw new Exception($"List with ID '{listId}' not found"); + } + + return await GetPreviewImages(list); + } + + public async Task GetPreviewImages(ListModel list) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from listToSeries in db.ListsToSeries + join series in db.Series on listToSeries.SeriesId equals series.SeriesId + where listToSeries.ListId == list.ListId + select series.PreviewImage; + + return await query.ToArrayAsync(); } } \ No newline at end of file diff --git a/server/AniStream/Controllers/CredentialsController.cs b/server/AniStream/Controllers/CredentialsController.cs index a68b4d0..386e57f 100644 --- a/server/AniStream/Controllers/CredentialsController.cs +++ b/server/AniStream/Controllers/CredentialsController.cs @@ -26,14 +26,15 @@ public CredentialsController(ICredentialsService credentialsService) [HttpPost("login")] public async Task Login([FromBody] LoginModel credentials) { - if (!await _credentialsService.ValidateCredentials(credentials.Username, credentials.Password)) + Models.ProfileModel? profile = await _credentialsService.ValidateCredentials(credentials.Username, credentials.Password); + if (profile is null) { return Unauthorized("Credentials are wrong"); } List claims = new List { - new Claim(ClaimTypes.Name, credentials.Username), + new Claim(ClaimTypes.Name, profile.Uuid), }; ClaimsIdentity identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); ClaimsPrincipal principal = new ClaimsPrincipal(identity); diff --git a/server/AniStream/Controllers/ListController.cs b/server/AniStream/Controllers/ListController.cs new file mode 100644 index 0000000..82e8cc0 --- /dev/null +++ b/server/AniStream/Controllers/ListController.cs @@ -0,0 +1,135 @@ +using AniStream.API.DTO; +using AniStream.API.Utils; +using AniStream.Contracts; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AniStream.API.Controllers; + +[Route("api/{provider}/lists")] +[ApiController] +[Authorize] +public sealed class ListController : ApiControllerBase +{ + private readonly IListService _listService; + + public ListController(IListService listService) + { + _listService = listService; + } + + [HttpGet] + public async Task GetLists() + { + Models.ListModel[] lists = await _listService.GetLists(); + + return lists.Select(list => list.ToDTO()).ToArray(); + } + + [HttpPost] + public async Task CreateList([FromBody] ListCreateModel data) + { + Models.ListModel list = await _listService.CreateList(data.Name); + + return list.ToDTO(); + } + + [HttpGet("{listId}")] + public async Task> GetList(int listId) + { + Models.ListModel? list = await _listService.GetList(listId); + if (list is null) + { + return NotFound($"List with ID '{listId}' not found"); + } + + return Ok(list.ToDTO()); + } + + [HttpPut("{listId}")] + public async Task> UpdateList(int listId, [FromBody] ListUpdateModel data) + { + Models.ListModel? list = await _listService.GetList(listId); + if (list is null) + { + return NotFound($"List with ID '{listId}' not found"); + } + + await _listService.UpdateList(list, data.Name); + + return Ok(list.ToDTO()); + } + + [HttpDelete("{listId}")] + public async Task DeleteList(int listId) + { + Models.ListModel? list = await _listService.GetList(listId); + if (list is null) + { + return NotFound($"List with ID '{listId}' not found"); + } + + await _listService.DeleteList(list); + return Ok(); + } + + [HttpGet("series/{seriesId}")] + public async Task GetListsOfSeries(int seriesId) + { + Models.ListModel[] lists = await _listService.GetListsOfSeries(seriesId); + + return lists.Select(list => list.ToDTO()).ToArray(); + } + + [HttpGet("{listId}/series")] + public async Task> GetSeries(int listId) + { + Models.ListModel? list = await _listService.GetList(listId); + if (list is null) + { + return NotFound($"List with ID '{listId}' not found"); + } + + Models.SeriesModel[] series = await _listService.GetSeries(list); + + return Ok(series.Select(series => series.ToDTO()).ToArray()); + } + + [HttpPost("{listId}/series/{seriesId}")] + public async Task AddSeriesToList(int listId, int seriesId) + { + Models.ListModel? list = await _listService.GetList(listId); + if (list is null) + { + return NotFound($"List with ID '{listId}' not found"); + } + + await _listService.AddSeriesToList(list, seriesId); + return Ok(); + } + + [HttpDelete("{listId}/series/{seriesId}")] + public async Task RemoveSeriesFromList(int listId, int seriesId) + { + Models.ListModel? list = await _listService.GetList(listId); + if (list is null) + { + return NotFound($"List with ID '{listId}' not found"); + } + + await _listService.RemoveSeriesFromList(list, seriesId); + return Ok(); + } + + [HttpGet("{listId}/preview")] + public async Task> GetPreviewImages(int listId) + { + Models.ListModel? list = await _listService.GetList(listId); + if (list is null) + { + return NotFound($"List with ID '{listId}' not found"); + } + + return Ok(await _listService.GetPreviewImages(list)); + } +} \ No newline at end of file diff --git a/server/AniStream/DTO/ListModel.cs b/server/AniStream/DTO/ListModel.cs new file mode 100644 index 0000000..b4596b4 --- /dev/null +++ b/server/AniStream/DTO/ListModel.cs @@ -0,0 +1,30 @@ +namespace AniStream.API.DTO; + +public sealed class ListModel +{ + public required int ListId { get; set; } + + public required string Name { get; set; } +} + +public sealed class ListCreateModel +{ + public required string Name { get; set; } +} + +public sealed class ListUpdateModel +{ + public required string Name { get; set; } +} + +internal static class ListModelHelper +{ + public static ListModel ToDTO(this Models.ListModel model) + { + return new ListModel + { + ListId = model.ListId, + Name = model.Name + }; + } +} \ No newline at end of file diff --git a/server/AniStream/Services/CredentialsService.cs b/server/AniStream/Services/CredentialsService.cs index 10ad507..33f84a3 100644 --- a/server/AniStream/Services/CredentialsService.cs +++ b/server/AniStream/Services/CredentialsService.cs @@ -6,21 +6,34 @@ namespace AniStream.API.Serivces; public sealed class CredentialsService : ICredentialsService { private readonly IUserService _userService; + private readonly HttpContext _context; - public CredentialsService(IUserService userService) + public CredentialsService(IUserService userService, HttpContext context) { _userService = userService; + _context = context; } - public async Task ValidateCredentials(string username, string password) + public async Task ValidateCredentials(string username, string password) { ProfileModel? profile = await _userService.GetProfile(username); if (profile is null) { - return false; + return null; } // TODO check password - return true; + return profile; + } + + public Task GetCurrentUuid() + { + string? guid = _context.User.Identity?.Name ?? null; + if (guid is null) + { + return Task.FromException(new Exception("Trying to access user in a non-authorized context")); + } + + return Task.FromResult(guid); } } \ No newline at end of file From d38ccce194d8429341d480e91940c936a1dec472 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 27 Apr 2026 17:22:21 +0200 Subject: [PATCH 041/134] Add tests for ListService --- server/AniStream.Tests/ListServiceTests.cs | 183 +++++++++++++++--- .../Utils/MockCredentialService.cs | 19 ++ server/AniStream.Tests/Utils/TestBase.cs | 3 +- 3 files changed, 178 insertions(+), 27 deletions(-) create mode 100644 server/AniStream.Tests/Utils/MockCredentialService.cs diff --git a/server/AniStream.Tests/ListServiceTests.cs b/server/AniStream.Tests/ListServiceTests.cs index fc66036..239cc7d 100644 --- a/server/AniStream.Tests/ListServiceTests.cs +++ b/server/AniStream.Tests/ListServiceTests.cs @@ -7,85 +7,216 @@ namespace AniStream.Tests; public sealed class ListServiceTests : TestBase { private readonly IListService _listService; + private readonly ISeriesService _seriesService; public ListServiceTests() { _listService = GetService(); + _seriesService = GetService(); } [Fact] - public async Task GetLists_ThrowsNotImplementedException() + public async Task GetLists() { - await Assert.ThrowsAsync(() => _listService.GetLists()); + await _listService.CreateList("watchlist"); + await _listService.CreateList("favorites"); + + ListModel[] lists = await _listService.GetLists(); + + Assert.Equal(2, lists.Length); + Assert.Contains(lists, l => l.Name == "watchlist"); + Assert.Contains(lists, l => l.Name == "favorites"); + } + + [Fact] + public async Task GetList() + { + ListModel created = await _listService.CreateList("watchlist"); + + ListModel? list = await _listService.GetList(created.ListId); + + Assert.NotNull(list); + Assert.Equal(created.ListId, list!.ListId); + Assert.Equal("watchlist", list.Name); + } + + [Fact] + public async Task GetListsOfSeries() + { + await CreateSeries(); + ListModel list1 = await _listService.CreateList("list1"); + ListModel list2 = await _listService.CreateList("list2"); + + await _listService.AddSeriesToList(list1.ListId, 1); + await _listService.AddSeriesToList(list2.ListId, 1); + + ListModel[] lists = await _listService.GetListsOfSeries(1); + + Assert.Equal(2, lists.Length); + Assert.Contains(lists, l => l.Name == "list1"); + Assert.Contains(lists, l => l.Name == "list2"); + } + + [Fact] + public async Task CreateList() + { + ListModel list = await _listService.CreateList("watchlist"); + + Assert.Equal(1, list.ListId); + Assert.Equal("watchlist", list.Name); + Assert.Equal(MockCredentialService.MOCK_UUID, list.TenantId); + } + + [Fact] + public async Task UpdateListById() + { + ListModel created = await _listService.CreateList("watchlist"); + + ListModel updated = await _listService.UpdateList(created.ListId, "updated"); + + Assert.Equal("updated", updated.Name); + + ListModel? fetched = await _listService.GetList(created.ListId); + Assert.Equal("updated", fetched!.Name); } [Fact] - public async Task GetList_ThrowsNotImplementedException() + public async Task UpdateListByModel() { - await Assert.ThrowsAsync(() => _listService.GetList(1)); + ListModel created = await _listService.CreateList("watchlist"); + + ListModel updated = await _listService.UpdateList(created, "updated"); + + Assert.Equal("updated", updated.Name); } [Fact] - public async Task GetListsOfSeries_ThrowsNotImplementedException() + public async Task DeleteListById() { - await Assert.ThrowsAsync(() => _listService.GetListsOfSeries(1)); + ListModel created = await _listService.CreateList("watchlist"); + + await _listService.DeleteList(created.ListId); + + ListModel? list = await _listService.GetList(created.ListId); + Assert.Null(list); } [Fact] - public async Task CreateList_ThrowsNotImplementedException() + public async Task DeleteListByModel() { - await Assert.ThrowsAsync(() => _listService.CreateList("watchlist")); + ListModel created = await _listService.CreateList("watchlist"); + + await _listService.DeleteList(created); + + ListModel? list = await _listService.GetList(created.ListId); + Assert.Null(list); } [Fact] - public async Task UpdateListById_ThrowsNotImplementedException() + public async Task GetSeriesById() { - await Assert.ThrowsAsync(() => _listService.UpdateList(1, "updated")); + ListModel list = await _listService.CreateList("watchlist"); + await CreateSeries(); + await _listService.AddSeriesToList(list.ListId, 1); + + SeriesModel[] seriesInList = await _listService.GetSeries(list.ListId); + + Assert.Single(seriesInList); + Assert.Equal(1, seriesInList[0].SeriesId); } [Fact] - public async Task UpdateListByModel_ThrowsNotImplementedException() + public async Task GetSeriesByModel() { - ListModel list = new ListModel("watchlist", ""); + ListModel list = await _listService.CreateList("watchlist"); + await CreateSeries(); + await _listService.AddSeriesToList(list, 1); - await Assert.ThrowsAsync(() => _listService.UpdateList(list, "updated")); + SeriesModel[] seriesInList = await _listService.GetSeries(list); + + Assert.Single(seriesInList); + Assert.Equal(1, seriesInList[0].SeriesId); } [Fact] - public async Task DeleteListById_ThrowsNotImplementedException() + public async Task AddSeriesToListById() { - await Assert.ThrowsAsync(() => _listService.DeleteList(1)); + ListModel list = await _listService.CreateList("watchlist"); + await CreateSeries(); + + await _listService.AddSeriesToList(list.ListId, 1); + + SeriesModel[] seriesInList = await _listService.GetSeries(list.ListId); + Assert.Single(seriesInList); } [Fact] - public async Task DeleteListByModel_ThrowsNotImplementedException() + public async Task AddSeriesToListByModel() { - ListModel list = new ListModel("watchlist", ""); + ListModel list = await _listService.CreateList("watchlist"); + await CreateSeries(); - await Assert.ThrowsAsync(() => _listService.DeleteList(list)); + await _listService.AddSeriesToList(list, 1); + + SeriesModel[] seriesInList = await _listService.GetSeries(list.ListId); + Assert.Single(seriesInList); } [Fact] - public async Task GetSeries_ThrowsNotImplementedException() + public async Task RemoveSeriesFromListById() { - await Assert.ThrowsAsync(() => _listService.GetSeries(1)); + ListModel list = await _listService.CreateList("watchlist"); + await CreateSeries(); + await _listService.AddSeriesToList(list.ListId, 1); + + await _listService.RemoveSeriesFromList(list.ListId, 1); + + SeriesModel[] seriesInList = await _listService.GetSeries(list.ListId); + Assert.Empty(seriesInList); } [Fact] - public async Task AddSeriesToList_ThrowsNotImplementedException() + public async Task RemoveSeriesFromListByModel() { - await Assert.ThrowsAsync(() => _listService.AddSeriesToList(1, 1)); + ListModel list = await _listService.CreateList("watchlist"); + await CreateSeries(); + await _listService.AddSeriesToList(list.ListId, 1); + + await _listService.RemoveSeriesFromList(list, 1); + + SeriesModel[] seriesInList = await _listService.GetSeries(list.ListId); + Assert.Empty(seriesInList); } [Fact] - public async Task RemoveSeriesFromList_ThrowsNotImplementedException() + public async Task GetPreviewImagesById() { - await Assert.ThrowsAsync(() => _listService.RemoveSeriesFromList(1, 1)); + ListModel list = await _listService.CreateList("watchlist"); + await CreateSeries(); + await _listService.AddSeriesToList(list.ListId, 1); + + string[] images = await _listService.GetPreviewImages(list.ListId); + + Assert.Single(images); + Assert.Equal("ABCDEFG", images[0]); } [Fact] - public async Task GetPreviewImages_ThrowsNotImplementedException() + public async Task GetPreviewImagesByModel() + { + ListModel list = await _listService.CreateList("watchlist"); + await CreateSeries(); + await _listService.AddSeriesToList(list.ListId, 1); + + string[] images = await _listService.GetPreviewImages(list); + + Assert.Single(images); + Assert.Equal("ABCDEFG", images[0]); + } + + private async Task CreateSeries() { - await Assert.ThrowsAsync(() => _listService.GetPreviewImages(1)); + SeriesModel series = await _seriesService.CreateSeries(Guid.NewGuid().ToString(), "Title", "Desc", "ABCDEFG"); + Assert.Equal(1, series.SeriesId); } } \ No newline at end of file diff --git a/server/AniStream.Tests/Utils/MockCredentialService.cs b/server/AniStream.Tests/Utils/MockCredentialService.cs new file mode 100644 index 0000000..3b72a96 --- /dev/null +++ b/server/AniStream.Tests/Utils/MockCredentialService.cs @@ -0,0 +1,19 @@ +using AniStream.Contracts; +using AniStream.Models; + +namespace AniStream.Tests.Utils; + +public sealed class MockCredentialService : ICredentialsService +{ + public const string MOCK_UUID = "test-tenant-id"; + + public Task ValidateCredentials(string username, string password) + { + throw new NotImplementedException(); + } + + public Task GetCurrentUuid() + { + return Task.FromResult(MOCK_UUID); + } +} diff --git a/server/AniStream.Tests/Utils/TestBase.cs b/server/AniStream.Tests/Utils/TestBase.cs index d6b6cc5..6700389 100644 --- a/server/AniStream.Tests/Utils/TestBase.cs +++ b/server/AniStream.Tests/Utils/TestBase.cs @@ -31,7 +31,8 @@ public TestBase(bool setProvider = true) _services.AddScoped>(_ => new MetadataDbContextFactory(MIGRATION_PATH)); _services.AddScoped>(_ => new ProfileDbContextFactory(MIGRATION_PATH)); - // TODO inject credentials service mock + _services.AddScoped(); + _provider = _services.BuildServiceProvider(); _scope = _provider.CreateScope(); From 306b617df9990155ce3a18a994a59b1ca1200858 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 27 Apr 2026 17:24:02 +0200 Subject: [PATCH 042/134] Remove Rider config file --- AniStream.sln.DotSettings.user | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 AniStream.sln.DotSettings.user diff --git a/AniStream.sln.DotSettings.user b/AniStream.sln.DotSettings.user deleted file mode 100644 index c722bea..0000000 --- a/AniStream.sln.DotSettings.user +++ /dev/null @@ -1,12 +0,0 @@ - - ForceIncluded - ForceIncluded - /home/christoph/.cache/JetBrains/Rider2026.1/resharper-host/temp/Rider/vAny/CoverageData/_AniStream.1268352179/Snapshot/snapshot.utdcvr - <SessionState ContinuousTestingMode="0" IsActive="True" Name="Junie Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Project Location="/home/christoph/Documents/Workbench/rust/AniStream/server/AniStream.Tests" Presentation="&lt;server&gt;\&lt;AniStream.Tests&gt;" /> -</SessionState> - <SessionState ContinuousTestingMode="0" Name="All tests from &lt;server&gt;\&lt;AniStream.Tests&gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Project Location="/home/christoph/Documents/Workbench/rust/AniStream/server/AniStream.Tests" Presentation="&lt;server&gt;\&lt;AniStream.Tests&gt;" /> -</SessionState> - - \ No newline at end of file From dc0fb8a8bcec60bba84001f57c27bb6453c78560 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 27 Apr 2026 17:25:10 +0200 Subject: [PATCH 043/134] Remove rider config --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2073fd9..3685658 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ dist-ssr # Editor directories and files .vscode .idea +AniStream.sln.DotSettings.user .DS_Store *.suo *.ntvs* From f4ecbbfe1814bd9a50655f541b3abafc812930bd Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Mon, 4 May 2026 14:10:43 +0200 Subject: [PATCH 044/134] Finalize basic testing suite --- app/package-lock.json | 500 +++++++++++++++++- app/package.json | 4 +- app/src/AppEnv.ts | 3 + app/src/{config.ts => configs/app.ts} | 20 +- app/src/configs/base.ts | 20 + app/src/configs/test.ts | 47 ++ app/src/controls/ImageHash.vue | 7 +- app/src/controls/InfoControl.model.ts | 5 +- app/src/controls/ListHash.vue | 9 +- app/src/main.ts | 6 +- app/src/providers/aniworld/fetcher.ts | 5 +- app/src/providers/aniworld/provider.ts | 21 +- app/src/providers/default.ts | 6 +- app/src/providers/sto/fetcher.ts | 5 +- app/src/providers/sto/provider.ts | 21 +- .../standalone/db/metadata.service.ts | 4 +- .../services/standalone/db/user.service.ts | 2 +- .../services/standalone/provider.service.ts | 10 +- app/src/services/standalone/user.service.ts | 7 +- app/src/utils/path.ts | 41 ++ app/tests/mocks/standalone/db.ts | 82 +++ .../mocks/standalone/metadata.service.ts | 30 ++ app/tests/mocks/standalone/user.service.ts | 40 ++ app/tests/services/genre.test.ts | 101 +++- app/tests/utils/harness.ts | 11 +- app/tests/utils/standalone.ts | 37 +- app/tsconfig.json | 2 + app/vite.config.ts | 5 + 28 files changed, 985 insertions(+), 66 deletions(-) create mode 100644 app/src/AppEnv.ts rename app/src/{config.ts => configs/app.ts} (83%) create mode 100644 app/src/configs/base.ts create mode 100644 app/src/configs/test.ts create mode 100644 app/src/utils/path.ts create mode 100644 app/tests/mocks/standalone/db.ts create mode 100644 app/tests/mocks/standalone/metadata.service.ts create mode 100644 app/tests/mocks/standalone/user.service.ts diff --git a/app/package-lock.json b/app/package-lock.json index f34b912..9cf0c2d 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -21,15 +21,17 @@ "hls.js": "^1.6.15", "vitest": "^4.1.5", "vue": "^3.5.13", - "vue-mvvm": "^0.7.0", + "vue-mvvm": "^0.9.0", "vue-router": "^5.0.4" }, "devDependencies": { "@tailwindcss/vite": "^4.2.2", "@tauri-apps/cli": "^2", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.0.6", "@types/random-useragent": "^0.3.3", "@vitejs/plugin-vue": "^6.0.5", + "better-sqlite3": "^12.9.0", "daisyui": "^5.5.14", "tailwindcss": "^4.1.18", "typescript": "~5.6.2", @@ -1485,6 +1487,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1955,6 +1967,52 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/birpc": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", @@ -1964,6 +2022,18 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/brace-expansion": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", @@ -1974,6 +2044,31 @@ "balanced-match": "^1.0.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1998,6 +2093,13 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -2033,6 +2135,32 @@ "dev": true, "license": "MIT" }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2042,6 +2170,16 @@ "node": ">=8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -2080,6 +2218,16 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2112,6 +2260,20 @@ } } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2126,6 +2288,13 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT" + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2155,6 +2324,41 @@ "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", "license": "MIT" }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -2491,6 +2695,19 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -2507,6 +2724,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -2560,6 +2794,26 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.90.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.90.0.tgz", + "integrity": "sha512-pZNQT7UnYlMwMBy5N1lV5X/YLTbZM5ncytN3xL7CHEzhDN8uVe0u55yaPUJICIJjaCW8NrM5BFdqr7HLweStNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -2570,6 +2824,16 @@ ], "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -2646,6 +2910,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -2662,6 +2965,37 @@ ], "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -2708,18 +3042,99 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/scule": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2741,6 +3156,26 @@ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -2762,6 +3197,36 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2809,6 +3274,19 @@ "license": "0BSD", "optional": true }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -2866,6 +3344,13 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.0.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.5.tgz", @@ -3061,9 +3546,9 @@ } }, "node_modules/vue-mvvm": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/vue-mvvm/-/vue-mvvm-0.7.0.tgz", - "integrity": "sha512-BO0BeGTSJUJQA6rE2nT7etpffDLKbiPvIN7qtATJ1BsgswISBrp6CMV5KSDAoO1Yn3bgQYa4fAP4W28ko4EpEg==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/vue-mvvm/-/vue-mvvm-0.9.0.tgz", + "integrity": "sha512-4TAqV+WNWqlOXj8a16WGoEHyo+80ONmSydARhM1xenGJSKT+w+4UB5hSdXYILVvMHKu5wGYko//E7U9TQMCmtw==", "license": "MIT", "peerDependencies": { "vue": "^3.5.24", @@ -3154,6 +3639,13 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/yaml": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", diff --git a/app/package.json b/app/package.json index 3b4e5e6..9ce7d14 100644 --- a/app/package.json +++ b/app/package.json @@ -24,15 +24,17 @@ "hls.js": "^1.6.15", "vitest": "^4.1.5", "vue": "^3.5.13", - "vue-mvvm": "^0.7.0", + "vue-mvvm": "^0.9.0", "vue-router": "^5.0.4" }, "devDependencies": { "@tailwindcss/vite": "^4.2.2", "@tauri-apps/cli": "^2", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.0.6", "@types/random-useragent": "^0.3.3", "@vitejs/plugin-vue": "^6.0.5", + "better-sqlite3": "^12.9.0", "daisyui": "^5.5.14", "tailwindcss": "^4.1.18", "typescript": "~5.6.2", diff --git a/app/src/AppEnv.ts b/app/src/AppEnv.ts new file mode 100644 index 0000000..ef5d566 --- /dev/null +++ b/app/src/AppEnv.ts @@ -0,0 +1,3 @@ +export const isTesting: boolean = import.meta.env.MODE === "test"; +export const isDev: boolean = import.meta.env.MODE === "development"; +export const isProd: boolean = !isTesting && !isDev; \ No newline at end of file diff --git a/app/src/config.ts b/app/src/configs/app.ts similarity index 83% rename from app/src/config.ts rename to app/src/configs/app.ts index 8a28c81..2022681 100644 --- a/app/src/config.ts +++ b/app/src/configs/app.ts @@ -1,6 +1,8 @@ import {App} from "vue"; import {AppShell, WritableGlobalContext} from "vue-mvvm"; +import {BaseConfig} from "@configs/base"; + import {ProviderViewModel} from "@views/ProviderView.model"; import {SettingsViewModel} from "@views/SettingsView.model"; import {StreamsViewModel} from "@views/StreamsView.model"; @@ -21,12 +23,10 @@ import {ReportService} from "@contracts/report.contract"; import {SettingsService} from "@contracts/settings.contract"; import {UpdateService} from "@contracts/update.contract"; -import {services} from "virtual:services"; - -export class AppConfig implements AppShell { +export class AppConfig extends BaseConfig { private app: App; - router: AppShell.RouterConfig = { + public router: AppShell.RouterConfig = { views: [ ProviderViewModel, SettingsViewModel, @@ -41,25 +41,23 @@ export class AppConfig implements AppShell { ] } - alert: AppShell.AlertConfig = { + public alert: AppShell.AlertConfig = { confirm: ConfirmControlModel } - toast: AppShell.ToastConfig = { + public toast: AppShell.ToastConfig = { container: ToastContainer, info: InfoToastModel, progress: ProgressToastModel } public constructor(app: App) { + super(); + this.app = app; } - configureServices(ctx: WritableGlobalContext): void { - for (const service of Object.values(services)) { - ctx.registerService(service.key, ctx => new service.ctor(ctx)); - } - + protected afterConfigureServices(ctx: WritableGlobalContext): void { // Init reporting const reportService: ReportService = ctx.getService(ReportService); this.app.config.errorHandler = async err => { diff --git a/app/src/configs/base.ts b/app/src/configs/base.ts new file mode 100644 index 0000000..5d36932 --- /dev/null +++ b/app/src/configs/base.ts @@ -0,0 +1,20 @@ +import {AppShell, WritableGlobalContext} from "vue-mvvm"; + +import {services} from "virtual:services"; + +export abstract class BaseConfig implements AppShell { + public abstract get router(): AppShell.RouterConfig; + public abstract get toast(): AppShell.ToastConfig; + public abstract get alert(): AppShell.AlertConfig; + + public configureServices(ctx: WritableGlobalContext): void { + for (const service of Object.values(services)) { + ctx.registerService(service.key, ctx => new service.ctor(ctx)); + } + + this.afterConfigureServices(ctx); + } + + protected abstract afterConfigureServices(ctx: WritableGlobalContext): void; + +} \ No newline at end of file diff --git a/app/src/configs/test.ts b/app/src/configs/test.ts new file mode 100644 index 0000000..10715cc --- /dev/null +++ b/app/src/configs/test.ts @@ -0,0 +1,47 @@ +import {AppShell, FactoryFunction, ReadableGlobalContext, ServiceKey, WritableGlobalContext} from "vue-mvvm"; + +import {BaseConfig} from "@configs/base"; + +import ToastContainer from "@controls/ToastContainer.vue"; + +export class TestConfig extends BaseConfig { + private _ctx: WritableGlobalContext | null; + + public router: AppShell.RouterConfig = { + views: [] + } + + public alert: AppShell.AlertConfig = { + } + + public toast: AppShell.ToastConfig = { + container: ToastContainer + } + + public get ctx(): ReadableGlobalContext { + if (!this._ctx) { + throw new Error("Context not initialized"); + } + + return this._ctx.toReadableGlobalContext(); + } + + public constructor() { + super(); + + this._ctx = null; + } + + public mockService(key: ServiceKey, factory: FactoryFunction): void { + if (!this._ctx) { + throw new Error("Context not initialized"); + } + + // @ts-expect-error + this._ctx.mockService(key, factory); + } + + protected afterConfigureServices(ctx: WritableGlobalContext): void { + this._ctx = ctx; + } +} \ No newline at end of file diff --git a/app/src/controls/ImageHash.vue b/app/src/controls/ImageHash.vue index 37f4652..352801f 100644 --- a/app/src/controls/ImageHash.vue +++ b/app/src/controls/ImageHash.vue @@ -1,9 +1,10 @@ + + + + diff --git a/app/src/views/SeriesSyncStandaloneView.vue b/app/src/views/SeriesSyncStandaloneView.vue index c8d3dda..a6853a3 100644 --- a/app/src/views/SeriesSyncStandaloneView.vue +++ b/app/src/views/SeriesSyncStandaloneView.vue @@ -1,14 +1,14 @@

- +

    @@ -51,7 +51,7 @@ const vm: SeriesSyncViewModel = useViewModel(SeriesSyncViewModel); :class="{'opacity-50 pointer-events-none': vm.isSyncing}" @click="vm.onAllAvailableRowClick">
    - +
    - + - + {{ season.season_number }}
    @@ -86,7 +86,7 @@ const vm: SeriesSyncViewModel = useViewModel(SeriesSyncViewModel); :class="{'opacity-50 pointer-events-none': vm.isSyncing}" @click="vm.onAllExistingRowClick">
    - +
    - + - + {{ season.season_number }}
    @@ -131,7 +131,7 @@ const vm: SeriesSyncViewModel = useViewModel(SeriesSyncViewModel); :disabled="vm.isPreLoading || vm.isSyncing || vm.selectedSeasons.length == 0" @click="vm.onStartSyncBtn"> - +
    diff --git a/app/src/views/SeriesSyncView.model.ts b/app/src/views/SeriesSyncView.model.ts index 350b4a6..0e3af76 100644 --- a/app/src/views/SeriesSyncView.model.ts +++ b/app/src/views/SeriesSyncView.model.ts @@ -2,11 +2,12 @@ import {Component} from "vue"; import {ViewModel} from "vue-mvvm"; import {RouteAdapter, RouterService} from "vue-mvvm/router"; -import SeriesSyncView from "@views/SeriesSyncView.vue"; +import SeriesSyncStandaloneView from "@views/SeriesSyncStandaloneView.vue"; +import SeriesSyncClientView from "@views/SeriesSyncClientView.vue"; import {StreamViewModel} from "@views/StreamView.model"; import {SeriesService} from "@contracts/series.contract"; -import {SeasonService} from "@contracts/season.contract"; +import {SeasonService, SyncInformation, SyncStatus} from "@contracts/season.contract"; import {FetchService} from "@contracts/fetch.contract"; import {EpisodeService} from "@contracts/episode.contract"; @@ -14,8 +15,9 @@ import {SeriesModel} from "@models/series.model"; import {SeasonFetchModel, SeasonModel} from "@models/season.model"; import {EpisodeFetchModel, EpisodeModel} from "@models/episode.model"; +import {NotImplementedError} from "@utils/error"; + export class SeriesSyncViewModel extends ViewModel { - public static readonly component: Component = SeriesSyncView; public static readonly route = { path: "/streams/:series_id/sync", params: { @@ -23,6 +25,14 @@ export class SeriesSyncViewModel extends ViewModel { } } satisfies RouteAdapter; + public static get component(): Component { + throw new NotImplementedError("get SeriesSyncViewModel.component"); + } +} + +export class SeriesSyncViewStandaloneModel extends SeriesSyncViewModel { + public static readonly component: Component = SeriesSyncStandaloneView; + private readonly routerService: RouterService; private readonly fetchService: FetchService; private readonly seriesService: SeriesService; @@ -225,4 +235,88 @@ export class SeriesSyncViewModel extends ViewModel { ); } } +} + +export class SeriesSyncViewClientModel extends SeriesSyncViewModel { + public static readonly component: Component = SeriesSyncClientView; + + private readonly routerService: RouterService; + + private readonly fetchService: FetchService; + private readonly seriesService: SeriesService; + private readonly seasonService: SeasonService; + + private refreshInterval: NodeJS.Timeout | null = null; + + private series: SeriesModel | null = this.ref(null); + private syncInformation: SyncInformation | null = this.ref(null); + + public isPreLoading: boolean = this.ref(true); + + public requiresSync: boolean = this.computed(() => !!this.syncInformation && this.syncInformation.requiresSync); + public isQueued: boolean = this.computed(() => this.syncInformation?.status === SyncStatus.Queued); + public isProcessing: boolean = this.computed(() => this.syncInformation?.status === SyncStatus.Processing); + + public canSync: boolean = this.computed(() => !this.isQueued && !this.isProcessing); + + public constructor() { + super(); + + this.routerService = this.ctx.getService(RouterService); + + this.fetchService = this.ctx.getService(FetchService); + this.seriesService = this.ctx.getService(SeriesService); + this.seasonService = this.ctx.getService(SeasonService); + + this.series = null; + } + + protected async mounted(): Promise { + this.isPreLoading = true; + + let seriesId: number | null = this.routerService.params.getIntegerOrDefault("series_id"); + if (!seriesId) { + this.routerService.navigateBack(); + return; + } + + this.series = await this.seriesService.getSeries(seriesId); + if (!this.series) { + this.routerService.navigateBack(); + return; + } + + this.syncInformation = await this.seasonService.getSyncStatus(seriesId); + + this.isPreLoading = false; + + if (!this.refreshInterval) { + this.refreshInterval = setInterval(async () => { + if (!this.series) { + return; + } + this.syncInformation = await this.seasonService.getSyncStatus(this.series.series_id) + }, 10_000); + + } + } + + protected async unmounted() { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + } + + public onBackBtn(): void { + this.routerService.navigateBack(); + } + + public async onSyncRequestBtn(): Promise { + if (!this.series) { + return; + } + await this.fetchService.startRemoteSyncing(this.series.series_id); + this.syncInformation = await this.seasonService.getSyncStatus(this.series.series_id); + } } \ No newline at end of file diff --git a/app/src/views/StreamsView.vue b/app/src/views/StreamsView.vue index a2ac42f..215a1ce 100644 --- a/app/src/views/StreamsView.vue +++ b/app/src/views/StreamsView.vue @@ -11,9 +11,11 @@ import LucideX from "@icons/LucideX.vue"; import I18n from "@utils/i18n"; -import Text from "@/controls/Text.vue"; +import Text from "@controls/Text.vue"; import ImageHash from "@controls/ImageHash.vue"; +import * as AppEnv from "@AppEnv"; + const vm: StreamsViewModel = useViewModel(StreamsViewModel); @@ -27,7 +29,7 @@ const vm: StreamsViewModel = useViewModel(StreamsViewModel);
    - From 935f867ae509e4e0396275561d6f5c948341044c Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Sat, 27 Jun 2026 12:37:34 +0200 Subject: [PATCH 124/134] New table definitions --- migration/sqlite/metadata/1.sql | 38 ++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/migration/sqlite/metadata/1.sql b/migration/sqlite/metadata/1.sql index 88653bf..8b75876 100644 --- a/migration/sqlite/metadata/1.sql +++ b/migration/sqlite/metadata/1.sql @@ -76,13 +76,35 @@ CREATE TABLE watchtime FOREIGN KEY (episode_id) REFERENCES episode (episode_id) ON DELETE CASCADE ); -CREATE TABLE sync_job +CREATE TABLE sync_series_job ( - sync_job_id INTEGER PRIMARY KEY AUTOINCREMENT, - series_id INTEGER NOT NULL, - status INTEGER NOT NULL, - started DATETIME NOT NULL, - completed DATETIME, - error TEXT, + sync_series_job_id INTEGER PRIMARY KEY AUTOINCREMENT, + series_id INTEGER NOT NULL, + status INTEGER NOT NULL, + started DATETIME NOT NULL, + completed DATETIME, + error TEXT, FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE CASCADE -); \ No newline at end of file +); + +CREATE TABLE sync_provider_job +( + sync_provider_job_id INTEGER PRIMARY KEY AUTOINCREMENT, + episode_id INTEGER NOT NULL, + status INTEGER NOT NULL, + started DATETIME NOT NULL, + completed DATETIME, + error TEXT, + expires DATETIME, + FOREIGN KEY (episode_id) REFERENCES episode (episode_id) ON DELETE CASCADE +); + +CREATE TABLE sync_provider_job_result +( + sync_provider_job_result_id INTEGER PRIMARY KEY AUTOINCREMENT, + sync_provider_job_id INTEGER NOT NULL, + provider TEXT NOT NULL, + url TEXT NOT NULL, + language_code INTEGER NOT NULL, + FOREIGN KEY (sync_provider_job_id) REFERENCES sync_provider_job (sync_provider_job_id) ON DELETE CASCADE +); From c49b6212911e28aa00da35f313a7705e7b3d3e72 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Sat, 27 Jun 2026 12:45:24 +0200 Subject: [PATCH 125/134] Add password column --- migration/sqlite/profile/1.sql | 2 ++ server/AniStream.Models/Models/ProfileModel.cs | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/migration/sqlite/profile/1.sql b/migration/sqlite/profile/1.sql index aeee5b1..9ebf87c 100644 --- a/migration/sqlite/profile/1.sql +++ b/migration/sqlite/profile/1.sql @@ -3,6 +3,8 @@ CREATE TABLE profile profile_id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT UNIQUE NOT NULL, name TEXT NOT NULL, + password TEXT NOT NULL, + password_salt TEXT NOT NULL, background_color TEXT NOT NULL, eye TEXT NOT NULL, mouth TEXT NOT NULL, diff --git a/server/AniStream.Models/Models/ProfileModel.cs b/server/AniStream.Models/Models/ProfileModel.cs index 2582404..44b8f0c 100644 --- a/server/AniStream.Models/Models/ProfileModel.cs +++ b/server/AniStream.Models/Models/ProfileModel.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Microsoft.EntityFrameworkCore; @@ -13,6 +14,10 @@ public sealed class ProfileModel public string Name { get; set; } + public string Password { get; set; } + + public string PasswordSalt { get; set; } + public string BackgroundColor { get; set; } public string Eye { get; set; } @@ -49,7 +54,7 @@ bool syncCatalog Theme = theme; Lang = lang; TosAccepted = tosAccepted; - SyncCatalog = syncCatalog; + SyncCatalog = syncCatalog; } public ProfileModel( From e585fc03fee4e29e56faba6f181c3f47ceac0d3a Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Sat, 27 Jun 2026 14:40:08 +0200 Subject: [PATCH 126/134] Implement PIN input --- app/src/controls/PinDialog.model.ts | 130 ++++++++++++++++++++++++ app/src/controls/PinDialog.vue | 53 ++++++++++ app/src/langs/control/PinDialog.de.json | 6 ++ app/src/langs/control/PinDialog.en.json | 6 ++ app/src/views/ProfileView.model.ts | 16 ++- 5 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 app/src/controls/PinDialog.model.ts create mode 100644 app/src/controls/PinDialog.vue create mode 100644 app/src/langs/control/PinDialog.de.json create mode 100644 app/src/langs/control/PinDialog.en.json diff --git a/app/src/controls/PinDialog.model.ts b/app/src/controls/PinDialog.model.ts new file mode 100644 index 0000000..a4bc1ed --- /dev/null +++ b/app/src/controls/PinDialog.model.ts @@ -0,0 +1,130 @@ +import {Component, nextTick} from "vue"; +import {Action, ActionContext, Delegate} from "vue-mvvm"; +import {DialogControl} from "vue-mvvm/dialog"; + +import {UserService} from "@contracts/user.contract"; + +import PinDialog from "@controls/PinDialog.vue"; + +import {ProfileModel} from "@models/profile.model"; + +export class PinDialogModel extends DialogControl implements Action { + public static readonly component: Component = PinDialog; + + private readonly userService: UserService; + + private readonly profile: ProfileModel; + + private actionCTX: ActionContext | null = null; + + public readonly onFocus: Delegate<[number]> = new Delegate<[number]>(); + public readonly pinLength: number = 6; + + public isOpen: boolean = this.ref(false); + public isTrying: boolean = this.ref(false); + public failed: boolean = this.ref(false); + public pin: string[] = this.ref(Array(6).fill("")); + + public constructor(profile: ProfileModel) { + super(); + + this.userService = this.ctx.getService(UserService); + + this.profile = profile; + } + + protected async onOpen(): Promise { + this.isOpen = true; + + await nextTick(); + await this.onFocus.invoke(0); + } + + protected onClose(): void { + if (this.actionCTX) { + this.actionCTX.failAction("Modal was closed"); + this.actionCTX = null; + } + this.isOpen = false; + } + + public onAction(ctx: ActionContext): void | Promise { + if (this.actionCTX) { + ctx.failAction("A action is already running"); + return; + } + + this.actionCTX = ctx; + } + + public async onInput(index: number, event: InputEvent): Promise { + if (!event.target) { + return; + } + + this.pin[index] = (event.target as HTMLInputElement).value.slice(-1); + await this.onFocus.invoke(index + 1); + + if (index == this.pinLength - 1) { + await this.onFullInput(); + } + } + + public async onKeyDown(index: number, event: KeyboardEvent): Promise { + if (event.key == "Backspace") { + event.preventDefault(); + this.pin[index] = ""; + await this.onFocus.invoke(index - 1); + return; + } + + if (event.key == "ArrowLeft" && index > 0) { + event.preventDefault(); + await this.onFocus.invoke(index - 1); + return; + } + + if (event.key == "ArrowRight") { + event.preventDefault(); + await this.onFocus.invoke(index + 1); + return; + } + } + + public async onPaste(event: ClipboardEvent): Promise { + event.preventDefault(); + + if (!event.clipboardData) { + return; + } + + const text: string = event.clipboardData.getData("text").slice(0, this.pinLength); + + text.split("").forEach((c: string, i: number): void => { + this.pin[i] = c; + }); + + const next: number = Math.min(text.length, this.pinLength - 1); + await this.onFocus.invoke(next); + } + + private async onFullInput(): Promise { + if (!this.actionCTX) { + return; + } + + const password: string = this.pin.join(""); + + this.isTrying = true; + this.failed = false; + if (await this.userService.authenticate(this.profile, password)) { + this.actionCTX.completeAction(); + } else { + this.pin = Array(this.pinLength).fill(""); + this.failed = true; + } + + this.isTrying = false; + nextTick().then(() => this.onFocus.invoke(0)) + } +} \ No newline at end of file diff --git a/app/src/controls/PinDialog.vue b/app/src/controls/PinDialog.vue new file mode 100644 index 0000000..88395fc --- /dev/null +++ b/app/src/controls/PinDialog.vue @@ -0,0 +1,53 @@ + + + diff --git a/app/src/langs/control/PinDialog.de.json b/app/src/langs/control/PinDialog.de.json new file mode 100644 index 0000000..6986b25 --- /dev/null +++ b/app/src/langs/control/PinDialog.de.json @@ -0,0 +1,6 @@ +{ + "$lang": "en", + "$group": "PinDialog", + "title": "PIN eingeben", + "wrong": "Die eingegebene PIN ist falsch" +} \ No newline at end of file diff --git a/app/src/langs/control/PinDialog.en.json b/app/src/langs/control/PinDialog.en.json new file mode 100644 index 0000000..fa01fcb --- /dev/null +++ b/app/src/langs/control/PinDialog.en.json @@ -0,0 +1,6 @@ +{ + "$lang": "en", + "$group": "PinDialog", + "title": "Enter PIN", + "wrong": "The entered pin was wrong" +} \ No newline at end of file diff --git a/app/src/views/ProfileView.model.ts b/app/src/views/ProfileView.model.ts index 1c2926b..95b2f39 100644 --- a/app/src/views/ProfileView.model.ts +++ b/app/src/views/ProfileView.model.ts @@ -1,5 +1,6 @@ import {Component, nextTick} from "vue"; import {ActionResult, ViewModel} from "vue-mvvm"; +import {DialogService} from "vue-mvvm/dialog"; import {RouteAdapter, RouterService} from "vue-mvvm/router"; import ProfileView from "@views/ProfileView.vue"; @@ -10,6 +11,7 @@ import {UserService} from "@contracts/user.contract"; import type {ProfileModel} from "@models/profile.model"; import {ProfileSetupControlModel} from "@controls/ProfileSetupControl.model"; +import {PinDialogModel} from "@controls/PinDialog.model"; import * as AppEnv from "@AppEnv"; @@ -20,6 +22,8 @@ export class ProfileViewModel extends ViewModel { } private readonly routerService: RouterService; + private readonly dialogService: DialogService; + private readonly userService: UserService; private readonly profileSetupControl: ProfileSetupControlModel | null; @@ -32,6 +36,8 @@ export class ProfileViewModel extends ViewModel { super(); this.routerService = this.ctx.getService(RouterService); + this.dialogService = this.ctx.getService(DialogService); + this.userService = this.ctx.getService(UserService); this.profileSetupControl = this.getUserControl("profile-setup-control"); @@ -59,11 +65,13 @@ export class ProfileViewModel extends ViewModel { public async onProfileItem(profile: ProfileModel): Promise { if (AppEnv.isClientMode) { - // TODO display dialog for pin input + using dialog: PinDialogModel = this.dialogService.initDialog(PinDialogModel, profile); + await dialog.openDialog(); - if (!await this.userService.authenticate(profile, "")) { - // TODO display error message in pin input - throw "Not implemented"; + const result: ActionResult = await this.runAction(dialog); + + if (!result.success) { + return; } } From 72e3692b6acf54a74da46bb5c386bfac9c80a45b Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Sat, 27 Jun 2026 14:44:51 +0200 Subject: [PATCH 127/134] Implement verification logic --- server/AniStream/AniStream.csproj | 2 ++ .../AniStream/Services/CredentialsService.cs | 36 +++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/server/AniStream/AniStream.csproj b/server/AniStream/AniStream.csproj index 4cb5036..2ef4ca8 100644 --- a/server/AniStream/AniStream.csproj +++ b/server/AniStream/AniStream.csproj @@ -10,9 +10,11 @@ + + diff --git a/server/AniStream/Services/CredentialsService.cs b/server/AniStream/Services/CredentialsService.cs index 1211510..af406ca 100644 --- a/server/AniStream/Services/CredentialsService.cs +++ b/server/AniStream/Services/CredentialsService.cs @@ -1,14 +1,17 @@ +using System.Security.Cryptography; +using System.Text; using AniStream.Contracts; using AniStream.Models; +using Konscious.Security.Cryptography; namespace AniStream.API.Serivces; public sealed class CredentialsService : ICredentialsService { private readonly IUserService _userService; - private readonly IHttpContextAccessor _context; + private readonly IHttpContextAccessor _context; - public CredentialsService(IUserService userService, IHttpContextAccessor context) + public CredentialsService(IUserService userService, IHttpContextAccessor context) { _userService = userService; _context = context; @@ -22,7 +25,11 @@ public CredentialsService(IUserService userService, IHttpContextAccessor contex return null; } - // TODO check password + if (!VerifyPassword(password, profile.Password, profile.PasswordSalt)) + { + return null; + } + return profile; } @@ -36,4 +43,27 @@ public Task GetCurrentUuid() return Task.FromResult(guid); } + + private bool VerifyPassword(string input, string passwordB64, string saltB64) + { + if (input.Length == 0) + { + return false; + } + + byte[] salt = Convert.FromBase64String(saltB64); + byte[] password = Convert.FromBase64String(passwordB64); + + Argon2id argon2 = new Argon2id(Encoding.UTF8.GetBytes(input)) + { + Salt = salt, + DegreeOfParallelism = 4, + MemorySize = 1024 * 1024, + Iterations = 3 + }; + + byte[] computedHash = argon2.GetBytes(32); + + return CryptographicOperations.FixedTimeEquals(computedHash, password); + } } \ No newline at end of file From 2fdd8a25773d3b02e4b6959264c6d23399e5bbb1 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Sat, 27 Jun 2026 14:49:08 +0200 Subject: [PATCH 128/134] Update CI --- .github/workflows/CI.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 9f85a5d..1615d29 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -25,6 +25,7 @@ jobs: cache-dependency-path: 'app/package-lock.json' - name: Setup ASP.NET Core + if: ${{ matrix.target == 'client' }} uses: actions/setup-dotnet@v5 with: dotnet-version: '9.0.x' @@ -37,12 +38,18 @@ jobs: - name: Run I18n generator run: node app/i18n.gen.cjs - - name: Build + - name: Build Frontend run: | cd app export APPLICATION_TARGET="${{ matrix.target }}" npm run build + - name: Build Backend + if: ${{ matrix.target == 'client' }} + run: | + cd server + dotnet build -c Test + - name: Run Unit tests run: | cd app From f889bd9ddbd265bad6fbd32013a68e13f1915932 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Sat, 27 Jun 2026 14:54:23 +0200 Subject: [PATCH 129/134] Respect new columns in CreateProfile function --- .../AniStream.Models/Models/ProfileModel.cs | 8 ++++++++ .../Contracts/IUserService.cs | 2 ++ .../Services/UserServiceImpl.cs | 4 +++- server/AniStream.Tests/UserServiceTests.cs | 20 +++++++++---------- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/server/AniStream.Models/Models/ProfileModel.cs b/server/AniStream.Models/Models/ProfileModel.cs index 44b8f0c..c895c1c 100644 --- a/server/AniStream.Models/Models/ProfileModel.cs +++ b/server/AniStream.Models/Models/ProfileModel.cs @@ -36,6 +36,8 @@ public ProfileModel( int profileId, string uuid, string name, + string password, + string passwordSalt, string backgroundColor, string eye, string mouth, @@ -48,6 +50,8 @@ bool syncCatalog ProfileId = profileId; Uuid = uuid; Name = name; + Password = password; + PasswordSalt = passwordSalt; BackgroundColor = backgroundColor; Eye = eye; Mouth = mouth; @@ -60,6 +64,8 @@ bool syncCatalog public ProfileModel( string uuid, string name, + string password, + string passwordSalt, string backgroundColor, string eye, string mouth, @@ -71,6 +77,8 @@ bool syncCatalog 0, uuid, name, + password, + passwordSalt, backgroundColor, eye, mouth, diff --git a/server/AniStream.Services/Contracts/IUserService.cs b/server/AniStream.Services/Contracts/IUserService.cs index 5a51d47..3346016 100644 --- a/server/AniStream.Services/Contracts/IUserService.cs +++ b/server/AniStream.Services/Contracts/IUserService.cs @@ -7,6 +7,8 @@ public interface IUserService public Task CreateProfile( string uuid, string name, + string password, + string passwordSalt, string backgroundColor, string eye, string mouth, diff --git a/server/AniStream.Services/Services/UserServiceImpl.cs b/server/AniStream.Services/Services/UserServiceImpl.cs index c890b17..79188db 100644 --- a/server/AniStream.Services/Services/UserServiceImpl.cs +++ b/server/AniStream.Services/Services/UserServiceImpl.cs @@ -18,6 +18,8 @@ public UserServiceImpl(DbContextFactory dbFactory) public async Task CreateProfile( string uuid, string name, + string password, + string passwordSalt, string backgroundColor, string eye, string mouth, @@ -29,7 +31,7 @@ bool syncCatalog { await using ProfileDbContext db = await _dbFactory.GetContext(); - ProfileModel profile = new ProfileModel(uuid, name, backgroundColor, eye, mouth, theme, lang, tosAccepted, syncCatalog); + ProfileModel profile = new ProfileModel(uuid, name, password, passwordSalt, backgroundColor, eye, mouth, theme, lang, tosAccepted, syncCatalog); db.Profiles.Add(profile); await db.SaveChangesAsync(); diff --git a/server/AniStream.Tests/UserServiceTests.cs b/server/AniStream.Tests/UserServiceTests.cs index 1e036eb..2eee73a 100644 --- a/server/AniStream.Tests/UserServiceTests.cs +++ b/server/AniStream.Tests/UserServiceTests.cs @@ -17,7 +17,7 @@ public UserServiceTests() public async Task CreateProfile() { Guid johnGuid = Guid.NewGuid(); - ProfileModel john = await _userService.CreateProfile(johnGuid.ToString(), "john", "fff", "eye-1", "mouth-1", "dark", "en", true, false); + ProfileModel john = await _userService.CreateProfile(johnGuid.ToString(), "john", "", "","fff", "eye-1", "mouth-1", "dark", "en", true, false); Assert.Equal(1, john.ProfileId); Assert.Equal("john", john.Name); @@ -28,8 +28,8 @@ public async Task GetProfiles() { Guid johnGuid = Guid.NewGuid(); Guid janeGuid = Guid.NewGuid(); - await _userService.CreateProfile(johnGuid.ToString(), "john", "fff", "eye-1", "mouth-1", "dark", "en", true, false); - await _userService.CreateProfile(janeGuid.ToString(), "jane", "000", "eye-2", "mouth-2", "light", "de", true, true); + await _userService.CreateProfile(johnGuid.ToString(), "john", "", "", "fff", "eye-1", "mouth-1", "dark", "en", true, false); + await _userService.CreateProfile(janeGuid.ToString(), "jane", "", "", "000", "eye-2", "mouth-2", "light", "de", true, true); ProfileModel[] profiles = await _userService.GetProfiles(); @@ -41,8 +41,8 @@ public async Task GetProfileByName() { Guid johnGuid = Guid.NewGuid(); Guid janeGuid = Guid.NewGuid(); - await _userService.CreateProfile(johnGuid.ToString(), "john", "fff", "eye-1", "mouth-1", "dark", "en", true, false); - await _userService.CreateProfile(janeGuid.ToString(), "jane", "000", "eye-2", "mouth-2", "light", "de", true, true); + await _userService.CreateProfile(johnGuid.ToString(), "john", "", "", "fff", "eye-1", "mouth-1", "dark", "en", true, false); + await _userService.CreateProfile(janeGuid.ToString(), "jane", "", "", "000", "eye-2", "mouth-2", "light", "de", true, true); ProfileModel? profileByName = await _userService.GetProfileByUsername("jane"); @@ -56,8 +56,8 @@ public async Task GetProfileByUuid() { Guid johnGuid = Guid.NewGuid(); Guid janeGuid = Guid.NewGuid(); - await _userService.CreateProfile(johnGuid.ToString(), "john", "fff", "eye-1", "mouth-1", "dark", "en", true, false); - await _userService.CreateProfile(janeGuid.ToString(), "jane", "000", "eye-2", "mouth-2", "light", "de", true, true); + await _userService.CreateProfile(johnGuid.ToString(), "john", "", "", "fff", "eye-1", "mouth-1", "dark", "en", true, false); + await _userService.CreateProfile(janeGuid.ToString(), "jane", "", "", "000", "eye-2", "mouth-2", "light", "de", true, true); ProfileModel? profileByUuid = await _userService.GetProfile(janeGuid.ToString()); @@ -69,7 +69,7 @@ public async Task GetProfileByUuid() public async Task GetProfileById() { Guid johnGuid = Guid.NewGuid(); - ProfileModel john = await _userService.CreateProfile(johnGuid.ToString(), "john", "fff", "eye-1", "mouth-1", "dark", "en", true, false); + ProfileModel john = await _userService.CreateProfile(johnGuid.ToString(), "john", "", "", "fff", "eye-1", "mouth-1", "dark", "en", true, false); ProfileModel? profileById = await _userService.GetProfile(john.ProfileId); @@ -87,7 +87,7 @@ public async Task GetActiveProfile_ThrowsNotImplementedException() public async Task UpdateProfileById() { Guid johnGuid = Guid.NewGuid(); - ProfileModel john = await _userService.CreateProfile(johnGuid.ToString(), "john", "fff", "eye-1", "mouth-1", "dark", "en", true, false); + ProfileModel john = await _userService.CreateProfile(johnGuid.ToString(), "john", "", "", "fff", "eye-1", "mouth-1", "dark", "en", true, false); ProfileModel updated = await _userService.UpdateProfile(john.ProfileId, name: "johnny", theme: "light"); @@ -100,7 +100,7 @@ public async Task UpdateProfileById() public async Task UpdateProfileByModel() { Guid johnGuid = Guid.NewGuid(); - ProfileModel john = await _userService.CreateProfile(johnGuid.ToString(), "john", "fff", "eye-1", "mouth-1", "dark", "en", true, false); + ProfileModel john = await _userService.CreateProfile(johnGuid.ToString(), "john", "", "", "fff", "eye-1", "mouth-1", "dark", "en", true, false); ProfileModel updated = await _userService.UpdateProfile(john, name: "johnny", theme: "light"); From c08e0158d36cb55f5e52c9e2f8a7d70d6d00e3fe Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Sat, 27 Jun 2026 14:56:07 +0200 Subject: [PATCH 130/134] Update CI.yml --- .github/workflows/CI.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 1615d29..4b4b117 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -46,9 +46,7 @@ jobs: - name: Build Backend if: ${{ matrix.target == 'client' }} - run: | - cd server - dotnet build -c Test + run: dotnet build ./AniStream.slnx -c Test - name: Run Unit tests run: | From 9c966935b60b77a943c457a2264872ac2d00161d Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Sat, 27 Jun 2026 15:00:00 +0200 Subject: [PATCH 131/134] Respect new columns in Test mode --- app/src/models/profile.model.ts | 2 ++ app/src/services/client/user.service.ts | 2 ++ server/AniStream/Controllers/ProfileController.cs | 2 ++ server/AniStream/DTO/ProfileModel.cs | 4 ++++ 4 files changed, 10 insertions(+) diff --git a/app/src/models/profile.model.ts b/app/src/models/profile.model.ts index 86fa634..e7fb2d3 100644 --- a/app/src/models/profile.model.ts +++ b/app/src/models/profile.model.ts @@ -37,6 +37,8 @@ export interface ProfileApiModel extends Omit(["api", "profiles"], { name, + password: "", + passwordSalt: "", background_color: backgroundColor, eye, mouth, diff --git a/server/AniStream/Controllers/ProfileController.cs b/server/AniStream/Controllers/ProfileController.cs index 1733093..a5a040b 100644 --- a/server/AniStream/Controllers/ProfileController.cs +++ b/server/AniStream/Controllers/ProfileController.cs @@ -107,6 +107,8 @@ public async Task> CreateProfile([FromBody] ProfileCr Models.ProfileModel profileModel = await _userService.CreateProfile( uuid, data.Name, + data.Password, + data.PasswordSalt, data.BackgroundColor, data.Eye, data.Mouth, diff --git a/server/AniStream/DTO/ProfileModel.cs b/server/AniStream/DTO/ProfileModel.cs index d8b2cd9..c88a30d 100644 --- a/server/AniStream/DTO/ProfileModel.cs +++ b/server/AniStream/DTO/ProfileModel.cs @@ -27,6 +27,10 @@ public sealed class ProfileCreateModel { public required string Name { get; set; } + public required string Password { get; set; } + + public required string PasswordSalt { get; set; } + public required string BackgroundColor { get; set; } public required string Eye { get; set; } From 1f06a27898044850bbcb737bbed093cebabf9173 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Sat, 27 Jun 2026 15:02:16 +0200 Subject: [PATCH 132/134] Use correct column name --- app/src/services/client/user.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/services/client/user.service.ts b/app/src/services/client/user.service.ts index 25febc7..fcfdfbf 100644 --- a/app/src/services/client/user.service.ts +++ b/app/src/services/client/user.service.ts @@ -158,7 +158,7 @@ export class UserServiceImpl extends ApiServiceBase implements UserService { const row: ProfileApiModel = await this.post(["api", "profiles"], { name, password: "", - passwordSalt: "", + password_salt: "", background_color: backgroundColor, eye, mouth, From b76116f3b10f6caf6a43fc74eb70b477ec63d226 Mon Sep 17 00:00:00 2001 From: Christoph Koschel Date: Sat, 27 Jun 2026 15:03:23 +0200 Subject: [PATCH 133/134] Modify DTO --- app/src/models/profile.model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/models/profile.model.ts b/app/src/models/profile.model.ts index e7fb2d3..4f5c79a 100644 --- a/app/src/models/profile.model.ts +++ b/app/src/models/profile.model.ts @@ -38,7 +38,7 @@ export interface ProfileApiModel extends Omit Date: Sun, 28 Jun 2026 16:59:14 +0200 Subject: [PATCH 134/134] Update settings view --- app/src/AppEnv.ts | 9 +- app/src/controls/InfoControl.model.ts | 67 +++++++------- app/src/controls/InfoControl.vue | 99 +++++++++++---------- app/src/controls/PrefControl.vue | 42 +++++---- app/src/langs/control/InfoControl.de.json | 1 + app/src/langs/control/InfoControl.en.json | 1 + app/src/services/client/settings.service.ts | 29 +++--- 7 files changed, 142 insertions(+), 106 deletions(-) diff --git a/app/src/AppEnv.ts b/app/src/AppEnv.ts index 50078e6..7623299 100644 --- a/app/src/AppEnv.ts +++ b/app/src/AppEnv.ts @@ -3,17 +3,22 @@ import * as os from "@tauri-apps/plugin-os"; type OSPlatforms = NodeJS.Platform | os.Platform; - export const isTesting: boolean = import.meta.env.MODE === "test"; export const isDev: boolean = import.meta.env.MODE === "development"; export const isProd: boolean = !isTesting && !isDev; - export const isClientMode: boolean = APPLICATION_TARGET == "client"; export const isStandaloneMode: boolean = APPLICATION_TARGET == "standalone"; export const isWorkerMode: boolean = APPLICATION_TARGET == "worker"; +export const modeName: string = isClientMode + ? "client" + : isStandaloneMode + ? "standalone" + : isWorkerMode + ? "worker" + : "?"; export const PLATFORM: OSPlatforms = isTesting || isWorkerMode diff --git a/app/src/controls/InfoControl.model.ts b/app/src/controls/InfoControl.model.ts index 85628ff..ba44e93 100644 --- a/app/src/controls/InfoControl.model.ts +++ b/app/src/controls/InfoControl.model.ts @@ -7,23 +7,26 @@ import {ProviderService} from "@contracts/provider.contract"; import * as path from "@utils/path"; +import * as AppEnv from "@AppEnv"; + import * as packageJSON from "@/../package.json"; export class InfoControlModel extends UserControl { private readonly providerService: ProviderService; - public version: string = this.computed(() => packageJSON.version); - public platform: string = this.computed(() => `${getPlatform()} ${getArch()}`); + public readonly version: string = this.computed(() => packageJSON.version); + public readonly platform: string = this.computed(() => `${getPlatform()} ${getArch()}`); + public readonly config: string = this.computed(() => AppEnv.modeName); public aniworldMetadataUsage: number = this.ref(0); - public aniworldMetadataUsagePercentage: number = this.computed(() => this.aniworldMetadataUsage / this.totalUsage * 100); + public readonly aniworldMetadataUsagePercentage: number = this.computed(() => this.aniworldMetadataUsage / this.totalUsage * 100); public aniworldAssetsUsage: number = this.ref(0) - public aniworldAssetsUsagePercentage: number = this.computed(() => this.aniworldAssetsUsage / this.totalUsage * 100); + public readonly aniworldAssetsUsagePercentage: number = this.computed(() => this.aniworldAssetsUsage / this.totalUsage * 100); public stoMetadataUsage: number = this.ref(0); - public stoMetadataUsagePercentage: number = this.computed(() => this.stoMetadataUsage / this.totalUsage * 100); + public readonly stoMetadataUsagePercentage: number = this.computed(() => this.stoMetadataUsage / this.totalUsage * 100); public stoAssetsUsage: number = this.ref(0); - public stoAssetsUsagePercentage: number = this.computed(() => this.stoAssetsUsage / this.totalUsage * 100); + public readonly stoAssetsUsagePercentage: number = this.computed(() => this.stoAssetsUsage / this.totalUsage * 100); - public totalUsage: number = this.computed(() => this.aniworldMetadataUsage + this.aniworldAssetsUsage + this.stoMetadataUsage + this.stoAssetsUsage) + public readonly totalUsage: number = this.computed(() => this.aniworldMetadataUsage + this.aniworldAssetsUsage + this.stoMetadataUsage + this.stoAssetsUsage) public constructor() { super(); @@ -32,10 +35,33 @@ export class InfoControlModel extends UserControl { } public async mounted(): Promise { - this.analyzeSpaceUsage() + if (AppEnv.isStandaloneMode) { + this.analyzeSpaceUsage() + } + } + + public formatBytes(bytes: number, postPoints: number = 2): string { + if (!Number.isFinite(bytes)) { + return "N/A"; + } + + const units: string[] = ["B", "KB", "MB", "GB"]; + + + let value: number = Math.abs(bytes); + let index: number = 0; + + while (value >= 1000 && index < units.length - 1) { + value /= 1000; + index++; + } + + const rounded: number = this.round(value, postPoints); + + return `${rounded} ${units[index]}`; } - public async analyzeSpaceUsage(): Promise { + private async analyzeSpaceUsage(): Promise { this.aniworldMetadataUsage = 0; this.aniworldAssetsUsage = 0; this.stoAssetsUsage = 0; @@ -64,32 +90,11 @@ export class InfoControlModel extends UserControl { await Promise.allSettled([aniworldWalker, stoWalker]); } - public round(value: number, postPoints: number): number { + private round(value: number, postPoints: number): number { const factor: number = Math.pow(10, postPoints); return Math.round(value * factor) / factor; } - public formatBytes(bytes: number, postPoints: number = 2): string { - if (!Number.isFinite(bytes)) { - return "N/A"; - } - - const units: string[] = ["B", "KB", "MB", "GB"]; - - - let value: number = Math.abs(bytes); - let index: number = 0; - - while (value >= 1000 && index < units.length - 1) { - value /= 1000; - index++; - } - - const rounded: number = this.round(value, postPoints); - - return `${rounded} ${units[index]}`; - } - private async walkTree(startFolder: string, cb: (entry: fs.DirEntry, stat: fs.FileInfo) => Promise): Promise { const entries: fs.DirEntry[] = await fs.readDir(startFolder); for (const entry of entries) { diff --git a/app/src/controls/InfoControl.vue b/app/src/controls/InfoControl.vue index f19e088..7062c81 100644 --- a/app/src/controls/InfoControl.vue +++ b/app/src/controls/InfoControl.vue @@ -8,6 +8,8 @@ import I18n from "@/utils/i18n"; import Text from "@controls/Text.vue"; +import * as AppEnv from "@AppEnv"; + import tos from "@/../../ToS.txt?raw"; import disclaimer from "@/../../LegalDisclaimer.txt?raw"; @@ -38,75 +40,82 @@ const vm: InfoControlModel = useUserControl(InfoControlModel); v{{ vm.version }} + + + + {{ vm.config }} {{ vm.platform }}
    -
    -
    -

    - -

    -
    -
    -
    -
    -
    -
    -
    -
    -

    -
    - -

    -
    +

    diff --git a/app/src/controls/PrefControl.vue b/app/src/controls/PrefControl.vue index 22f09b6..0cf5e15 100644 --- a/app/src/controls/PrefControl.vue +++ b/app/src/controls/PrefControl.vue @@ -9,6 +9,8 @@ import Text from "@controls/Text.vue"; import LucideEdit from "@icons/LucideEdit.vue"; import LucideTrash from "@icons/LucideTrash.vue"; +import * as AppEnv from "@AppEnv"; + const vm: PrefControlModel = useUserControl(PrefControlModel); @@ -21,8 +23,10 @@ const vm: PrefControlModel = useUserControl(PrefControlModel); class="w-24 rounded-2xl ring ring-primary ring-offset-base-100 ring-offset-4 overflow-hidden bg-base-200"> Avatar Preview

    -
    @@ -186,27 +190,29 @@ const vm: PrefControlModel = useUserControl(PrefControlModel);
    -
    - -
    -

    - -

    - -
    +
    diff --git a/app/src/langs/control/InfoControl.de.json b/app/src/langs/control/InfoControl.de.json index 01e5b10..0bcf9df 100644 --- a/app/src/langs/control/InfoControl.de.json +++ b/app/src/langs/control/InfoControl.de.json @@ -5,6 +5,7 @@ "application": { "title": "Anwendung", "version": "Version", + "config": "Anwendungsconfig", "platform": "Plattform" }, "storage": { diff --git a/app/src/langs/control/InfoControl.en.json b/app/src/langs/control/InfoControl.en.json index aeef4ee..11c0ecf 100644 --- a/app/src/langs/control/InfoControl.en.json +++ b/app/src/langs/control/InfoControl.en.json @@ -5,6 +5,7 @@ "application": { "title": "Application", "version": "Version", + "config": "Application Config", "platform": "Platform" }, "storage": { diff --git a/app/src/services/client/settings.service.ts b/app/src/services/client/settings.service.ts index f394c6d..a29a534 100644 --- a/app/src/services/client/settings.service.ts +++ b/app/src/services/client/settings.service.ts @@ -7,34 +7,38 @@ import {I18nService, SupportedLocals} from "@contracts/i18n.contract"; import {SettingsService} from "@contracts/settings.contract"; import {UserService} from "@contracts/user.contract"; -import {UnsupportedPlatformError} from "@utils/error"; - import {ProfileModel} from "@models/profile.model"; import * as AppEnv from "@AppEnv"; export class SettingsServiceImpl implements SettingsService { + private static readonly IGNORE_VERSION_KEY: string = "ignore-version"; + private static readonly UPDATES_ACTIVE_KEY: string = "updates-active"; private static readonly HEALTHZ_KEY: string = "healthz"; + private _ignoreVersion: Ref = ref(""); + private _updatesActive: Ref = ref(false); private _healthz: Ref = ref(""); private readonly i18nService: I18nService; private readonly userService: UserService; - public get ignoreVersion(): Readonly> { - return readonly(ref("0.0.0")); + public get ignoreVersion(): Readonly> { + return readonly(this._ignoreVersion); } - public set ignoreVersion(_v: Readonly>) { - throw new UnsupportedPlatformError("set SettingsServiceImpl.ignoreVersion"); + public set ignoreVersion(v: string) { + this._ignoreVersion.value = v; + localStorage.setItem(SettingsServiceImpl.IGNORE_VERSION_KEY, v); } - public get updatesActive(): Readonly> { - return readonly(ref(false)); + public get updatesActive(): Readonly> { + return readonly(this._updatesActive); } - public set updatesActive(_v: Readonly>) { - throw new UnsupportedPlatformError("set SettingsServiceImpl.updatesActive"); + public set updatesActive(v: boolean) { + this._updatesActive.value = v; + localStorage.setItem(SettingsServiceImpl.UPDATES_ACTIVE_KEY, v ? "true" : "false"); } public get healthz(): Readonly> { @@ -54,7 +58,12 @@ export class SettingsServiceImpl implements SettingsService { return; } + this.ignoreVersion = this.loadFromStorage(SettingsServiceImpl.IGNORE_VERSION_KEY, "0.0.0"); + this.updatesActive = this.loadFromStorage(SettingsServiceImpl.UPDATES_ACTIVE_KEY, "true") == "true"; this.healthz = this.loadFromStorage(SettingsServiceImpl.HEALTHZ_KEY, "https://www.google.com/generate_204"); + + this.setThemeSession(this.getDefaultTheme()); + this.setLocalSession(this.getDefaultLocal()); } public async setTheme(theme: string): Promise {