From d5af0064435906b6f85a85417ea2ee9bd39a5444 Mon Sep 17 00:00:00 2001 From: Devil Date: Tue, 11 Aug 2026 12:27:44 +0200 Subject: [PATCH] Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs) --- .claude/settings.json | 9 + .gitignore | 31 + CLAUDE.md | 22 + backend/.dockerignore | 8 + backend/.env.example | 27 + backend/Dockerfile | 32 + backend/README.md | 33 + backend/docs/architecture.html | 1133 + backend/docs/guide-part1.html | 883 + backend/docs/guide-part2.html | 666 + backend/docs/guide-part3.html | 1118 + backend/nest-cli.json | 11 + backend/package-lock.json | 9993 +++++++ backend/package.json | 72 + backend/prisma/make-admin.ts | 27 + .../20260429131107_init/migration.sql | 347 + .../migration.sql | 175 + .../migration.sql | 23 + .../migration.sql | 2 + .../migration.sql | 50 + .../migration.sql | 2 + .../migration.sql | 2 + .../migration.sql | 9 + .../migration.sql | 1 + .../migration.sql | 1 + .../migration.sql | 17 + .../migration.sql | 1 + .../migration.sql | 2 + .../migration.sql | 3 + .../migration.sql | 2 + .../migration.sql | 1 + .../migration.sql | 1 + .../migration.sql | 1 + .../migration.sql | 1 + .../migration.sql | 15 + .../migration.sql | 2 + .../migration.sql | 3 + .../migration.sql | 8 + backend/prisma/migrations/migration_lock.toml | 3 + backend/prisma/schema.prisma | 492 + backend/scripts/import-missing-videos.mjs | 112 + backend/scripts/list-playlist.mjs | 44 + backend/scripts/playlist-raw.json | 21938 ++++++++++++++++ backend/src/.vs/ProjectSettings.json | 3 + backend/src/.vs/VSWorkspaceState.json | 7 + backend/src/.vs/slnx.sqlite | Bin 0 -> 176128 bytes ...d61e9a84-ca45-40f6-9df8-199e679bcccb.vsidx | Bin 0 -> 144940 bytes backend/src/.vs/src/v17/.wsuo | Bin 0 -> 16384 bytes backend/src/.vs/src/v17/DocumentLayout.json | 41 + backend/src/app.module.ts | 64 + backend/src/health.controller.ts | 11 + backend/src/main.ts | 42 + .../audit-logs/audit-logs.controller.ts | 79 + backend/src/modules/auth/auth.controller.ts | 91 + backend/src/modules/auth/auth.module.ts | 26 + backend/src/modules/auth/auth.service.ts | 218 + .../auth/decorators/roles.decorator.ts | 5 + .../src/modules/auth/guards/jwt-auth.guard.ts | 5 + .../src/modules/auth/guards/roles.guard.ts | 35 + .../auth/strategies/google.strategy.ts | 68 + .../modules/auth/strategies/jwt.strategy.ts | 39 + .../src/modules/blocks/blocks.controller.ts | 35 + backend/src/modules/blocks/blocks.module.ts | 14 + backend/src/modules/blocks/blocks.service.ts | 107 + .../modules/blocks/dto/create-block.dto.ts | 22 + .../modules/blocks/dto/update-block.dto.ts | 14 + .../modules/bulk-jobs/bulk-jobs.controller.ts | 38 + .../src/modules/bulk-jobs/bulk-jobs.module.ts | 14 + .../modules/bulk-jobs/bulk-jobs.service.ts | 237 + .../modules/calendar/calendar.controller.ts | 20 + .../src/modules/calendar/calendar.module.ts | 9 + .../src/modules/calendar/calendar.service.ts | 70 + .../modules/campaigns/campaigns.controller.ts | 22 + .../src/modules/campaigns/campaigns.module.ts | 9 + .../collaborators/collaborators.controller.ts | 32 + .../collaborators/collaborators.module.ts | 14 + .../collaborators/collaborators.service.ts | 69 + .../dto/create-collaborator.dto.ts | 15 + .../dto/update-collaborator.dto.ts | 16 + .../src/modules/exports/exports.controller.ts | 28 + backend/src/modules/exports/exports.module.ts | 9 + .../src/modules/exports/exports.service.ts | 64 + .../src/modules/imports/imports.controller.ts | 46 + backend/src/modules/imports/imports.module.ts | 14 + .../src/modules/imports/imports.service.ts | 143 + .../src/modules/linting/linting.controller.ts | 194 + backend/src/modules/linting/linting.module.ts | 13 + .../src/modules/linting/linting.service.ts | 112 + .../src/modules/linting/rules/base.rule.ts | 13 + .../rules/desc-duplicate-hashtag.rule.ts | 26 + .../rules/desc-empty-placeholder.rule.ts | 22 + .../rules/desc-missing-chapters.rule.ts | 23 + .../linting/rules/desc-missing-cta.rule.ts | 22 + .../rules/desc-outdated-sponsor.rule.ts | 30 + .../rules/desc-required-link-missing.rule.ts | 28 + .../linting/rules/remote-conflict.rule.ts | 18 + .../linting/rules/title-too-long.rule.ts | 19 + .../modules/linting/rules/title-weak.rule.ts | 21 + .../modules/playlists/playlists.controller.ts | 48 + .../src/modules/playlists/playlists.module.ts | 11 + .../modules/playlists/playlists.service.ts | 120 + backend/src/modules/quota/quota.controller.ts | 108 + .../saved-views/saved-views.controller.ts | 37 + .../modules/saved-views/saved-views.module.ts | 12 + .../saved-views/saved-views.service.ts | 58 + .../dto/upsert-team-variable.dto.ts | 7 + .../team-variables.controller.ts | 33 + .../team-variables/team-variables.module.ts | 14 + .../team-variables/team-variables.service.ts | 76 + backend/src/modules/teams/teams.controller.ts | 89 + backend/src/modules/teams/teams.module.ts | 12 + backend/src/modules/teams/teams.service.ts | 344 + .../templates/dto/create-template.dto.ts | 12 + .../templates/dto/update-template.dto.ts | 13 + .../modules/templates/templates.controller.ts | 50 + .../src/modules/templates/templates.module.ts | 13 + .../modules/templates/templates.service.ts | 164 + .../dto/upsert-video-config.dto.ts | 11 + .../video-configs/video-configs.controller.ts | 29 + .../video-configs/video-configs.module.ts | 20 + .../video-configs/video-configs.service.ts | 99 + .../modules/videos/dto/bulk-preview.dto.ts | 9 + .../modules/videos/dto/query-videos.dto.ts | 43 + .../modules/videos/dto/update-video.dto.ts | 22 + .../src/modules/videos/videos.controller.ts | 97 + backend/src/modules/videos/videos.module.ts | 21 + backend/src/modules/videos/videos.service.ts | 522 + .../youtube-sync/channel-import.service.ts | 476 + .../youtube-sync/youtube-api.client.ts | 239 + .../youtube-sync/youtube-sync.controller.ts | 115 + .../youtube-sync/youtube-sync.module.ts | 17 + .../youtube-sync/youtube-sync.service.ts | 147 + .../processors/bulk-metadata.processor.ts | 83 + .../conflict-detection.processor.ts | 77 + .../src/queues/processors/import.processor.ts | 16 + .../src/queues/processors/lint.processor.ts | 16 + .../src/queues/processors/render.processor.ts | 36 + .../processors/youtube-sync.processor.ts | 164 + backend/src/queues/queues.constants.ts | 8 + .../conflict-detection.scheduler.ts | 40 + backend/src/shared/audit/audit.module.ts | 8 + backend/src/shared/audit/audit.service.ts | 29 + backend/src/shared/prisma/prisma.module.ts | 9 + backend/src/shared/prisma/prisma.service.ts | 13 + backend/src/shared/quota/quota.module.ts | 8 + backend/src/shared/quota/quota.service.ts | 93 + backend/src/shared/render-engine/hash.ts | 21 + .../shared/render-engine/pending-changes.ts | 50 + .../render-engine/render-engine.module.ts | 9 + .../render-engine/render-engine.service.ts | 332 + .../render-engine/video-render.service.ts | 71 + .../system-variables.controller.ts | 13 + .../system-variables.module.ts | 7 + .../system-variables.registry.ts | 177 + backend/src/worker.module.ts | 57 + backend/src/worker.ts | 10 + backend/tsconfig.json | 21 + claude.bat | 2 + .../YouTube Studio Flow/.obsidian/app.json | 1 + .../.obsidian/appearance.json | 1 + .../.obsidian/core-plugins.json | 33 + .../YouTube Studio Flow/.obsidian/graph.json | 22 + .../YouTube Studio Flow/00 - Claude Guide.md | 60 + .../YouTube Studio Flow/00 - Index.md | 84 + .../01 - Architecture/01 - System Overview.md | 65 + .../01 - Architecture/02 - Backend.md | 99 + .../01 - Architecture/03 - Frontend.md | 99 + .../01 - Architecture/04 - Database Schema.md | 453 + .../01 - Architecture/05 - Queue System.md | 112 + .../01 - Architecture/06 - Authentication.md | 112 + .../01 - Architecture/07 - Render Engine.md | 151 + .../02 - Features/01 - Video Management.md | 56 + .../02 - Features/02 - Description Engine.md | 88 + .../02 - Features/03 - Metadata Linting.md | 65 + .../02 - Features/04 - Bulk Operations.md | 42 + .../02 - Features/05 - Playlists.md | 26 + .../02 - Features/06 - Collaborators.md | 41 + .../02 - Features/07 - Saved Views.md | 41 + .../02 - Features/08 - Campaigns.md | 35 + .../02 - Features/09 - Calendar.md | 24 + .../02 - Features/10 - Import Export.md | 33 + .../02 - Features/11 - Quota Management.md | 31 + .../02 - Features/12 - Audit Log.md | 34 + .../02 - Features/13 - Team Settings.md | 56 + .../03 - API Reference/01 - Auth API.md | 129 + .../03 - API Reference/02 - Videos API.md | 160 + .../03 - API Reference/03 - Blocks API.md | 97 + .../03 - API Reference/04 - Templates API.md | 125 + .../05 - Collaborators API.md | 93 + .../03 - API Reference/06 - Linting API.md | 142 + .../03 - API Reference/07 - Playlists API.md | 57 + .../08 - Saved Views API.md | 91 + .../03 - API Reference/09 - Teams API.md | 161 + .../03 - API Reference/10 - Quota API.md | 78 + .../03 - API Reference/11 - Bulk Jobs API.md | 148 + .../03 - API Reference/12 - Calendar API.md | 61 + .../13 - Import Export API.md | 160 + .../14 - Team Variables API.md | 133 + .../03 - API Reference/15 - Campaigns API.md | 55 + .../16 - YouTube Sync API.md | 200 + .../17 - Video Configs API.md | 102 + .../01 - Visual Design.md | 154 + .../02 - CSS Conventions.md | 188 + .../03 - Component Patterns.md | 178 + .../04 - Backend Architecture Patterns.md | 251 + .../05 - Code Conventions.md | 160 + .../05 - Development/01 - Local Setup.md | 94 + .../02 - Environment Variables.md | 68 + .../05 - Development/03 - Recipes.md | 209 + .../05 - Development/04 - Gotchas.md | 196 + .../05 - Deployment and Operations.md | 99 + .../06 - Verifying Changes.md | 82 + .../01 - Technical Debt and Future Work.md | 190 + ... 01 Scheduled Remote Conflict Detection.md | 88 + ...026-07-04 01 Batched Conflict Detection.md | 55 + export-data.ps1 | 113 + frontend/.dockerignore | 7 + frontend/.eslintrc.json | 6 + frontend/Dockerfile | 30 + frontend/FRONTEND_PLAN.md | 63 + frontend/README.md | 29 + frontend/next-env.d.ts | 6 + frontend/next.config.ts | 14 + frontend/package-lock.json | 5850 ++++ frontend/package.json | 36 + .../(dashboard)/DashboardLayout.module.css | 18 + .../src/app/(dashboard)/audit/page.module.css | 303 + frontend/src/app/(dashboard)/audit/page.tsx | 228 + .../app/(dashboard)/blocks/page.module.css | 547 + frontend/src/app/(dashboard)/blocks/page.tsx | 556 + .../app/(dashboard)/bulk-jobs/page.module.css | 159 + .../src/app/(dashboard)/bulk-jobs/page.tsx | 118 + .../app/(dashboard)/calendar/page.module.css | 253 + .../src/app/(dashboard)/calendar/page.tsx | 155 + .../(dashboard)/collaborators/page.module.css | 229 + .../app/(dashboard)/collaborators/page.tsx | 167 + frontend/src/app/(dashboard)/layout.tsx | 27 + .../app/(dashboard)/linting/page.module.css | 401 + frontend/src/app/(dashboard)/linting/page.tsx | 456 + .../app/(dashboard)/overview/page.module.css | 230 + .../src/app/(dashboard)/overview/page.tsx | 332 + frontend/src/app/(dashboard)/page.module.css | 213 + frontend/src/app/(dashboard)/page.tsx | 5 + .../(dashboard)/quota-history/page.module.css | 438 + .../app/(dashboard)/quota-history/page.tsx | 436 + .../app/(dashboard)/settings/page.module.css | 566 + .../src/app/(dashboard)/settings/page.tsx | 729 + .../app/(dashboard)/templates/page.module.css | 520 + .../src/app/(dashboard)/templates/page.tsx | 395 + .../app/(dashboard)/variables/page.module.css | 243 + .../src/app/(dashboard)/variables/page.tsx | 304 + .../videos/PushPendingModal.module.css | 325 + .../(dashboard)/videos/PushPendingModal.tsx | 246 + .../(dashboard)/videos/[id]/page.module.css | 730 + .../src/app/(dashboard)/videos/[id]/page.tsx | 1238 + .../app/(dashboard)/videos/page.module.css | 175 + frontend/src/app/(dashboard)/videos/page.tsx | 451 + .../src/app/auth/callback/CallbackHandler.tsx | 49 + frontend/src/app/auth/callback/page.tsx | 10 + frontend/src/app/layout.tsx | 21 + frontend/src/app/login/Login.module.css | 95 + frontend/src/app/login/page.tsx | 39 + frontend/src/app/page.tsx | 5 + frontend/src/components/shared/AuthGuard.tsx | 25 + .../shared/BlockOrderEditor.module.css | 608 + .../components/shared/BlockOrderEditor.tsx | 550 + .../components/shared/CollaboratorModal.tsx | 218 + .../components/shared/ColumnPicker.module.css | 112 + .../src/components/shared/ColumnPicker.tsx | 148 + .../components/shared/FormField.module.css | 111 + .../src/components/shared/Header.module.css | 286 + frontend/src/components/shared/Header.tsx | 382 + .../src/components/shared/Modal.module.css | 62 + frontend/src/components/shared/Modal.tsx | 38 + frontend/src/components/shared/Providers.tsx | 21 + .../components/shared/QuotaWidget.module.css | 35 + .../src/components/shared/QuotaWidget.tsx | 33 + .../shared/SavedViewManager.module.css | 196 + .../components/shared/SavedViewManager.tsx | 171 + .../src/components/shared/Sidebar.module.css | 156 + frontend/src/components/shared/Sidebar.tsx | 107 + .../shared/SortableSection.module.css | 49 + .../src/components/shared/SortableSection.tsx | 41 + .../components/shared/UsagePanel.module.css | 80 + frontend/src/components/shared/UsagePanel.tsx | 54 + .../video-config/VideoConfigEditor.module.css | 621 + .../video-config/VideoConfigEditor.tsx | 141 + .../video-table/VideoTable.module.css | 356 + .../src/components/video-table/VideoTable.tsx | 649 + frontend/src/hooks/useColumnPreferences.ts | 47 + frontend/src/hooks/useTheme.ts | 23 + frontend/src/lib/api-client.ts | 72 + frontend/src/lib/api.ts | 822 + frontend/src/lib/videoFieldOptions.ts | 50 + frontend/src/middleware.ts | 24 + frontend/src/store/useAuthStore.ts | 30 + frontend/src/store/useUIStore.ts | 21 + frontend/src/styles/globals.css | 207 + frontend/tsconfig.json | 27 + import-data.ps1 | 160 + infrastructure/.env.example | 36 + infrastructure/README.md | 21 + infrastructure/docker-compose.yml | 153 + setup-dev-environment.ps1 | 110 + 304 files changed, 74604 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 backend/.dockerignore create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/docs/architecture.html create mode 100644 backend/docs/guide-part1.html create mode 100644 backend/docs/guide-part2.html create mode 100644 backend/docs/guide-part3.html create mode 100644 backend/nest-cli.json create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/prisma/make-admin.ts create mode 100644 backend/prisma/migrations/20260429131107_init/migration.sql create mode 100644 backend/prisma/migrations/20260521104943_add_multi_tenancy/migration.sql create mode 100644 backend/prisma/migrations/20260521132929_add_team_variables_and_block_var_defs/migration.sql create mode 100644 backend/prisma/migrations/20260521134945_add_youtube_description/migration.sql create mode 100644 backend/prisma/migrations/20260521141202_add_video_extended_fields_and_playlists/migration.sql create mode 100644 backend/prisma/migrations/20260521143907_add_user_preferences/migration.sql create mode 100644 backend/prisma/migrations/20260525000000_make_youtube_handle_optional/migration.sql create mode 100644 backend/prisma/migrations/20260525000001_rename_youtube_handle_to_link/migration.sql create mode 100644 backend/prisma/migrations/20260525000002_add_block_compact_default/migration.sql create mode 100644 backend/prisma/migrations/20260525000003_add_template_default_overrides/migration.sql create mode 100644 backend/prisma/migrations/20260526000000_consolidate_collaborator_ids/migration.sql create mode 100644 backend/prisma/migrations/20260527000000_add_thumbnail_url/migration.sql create mode 100644 backend/prisma/migrations/20260602000000_add_youtube_snapshot/migration.sql create mode 100644 backend/prisma/migrations/20260602000001_quota_log_action_tracking/migration.sql create mode 100644 backend/prisma/migrations/20260605000000_publishing_schedule/migration.sql create mode 100644 backend/prisma/migrations/20260605000001_show_canva_link/migration.sql create mode 100644 backend/prisma/migrations/20260605000002_disabled_lint_rules/migration.sql create mode 100644 backend/prisma/migrations/20260606000000_video_youtube_deleted_at/migration.sql create mode 100644 backend/prisma/migrations/20260606000001_team_show_deleted_videos/migration.sql create mode 100644 backend/prisma/migrations/20260608000001_capture_schema_drift/migration.sql create mode 100644 backend/prisma/migrations/20260608000002_fix_supplemental_video_ids_default/migration.sql create mode 100644 backend/prisma/migrations/20260608173234_add_saved_view_tab_fields/migration.sql create mode 100644 backend/prisma/migrations/20260629000000_add_conflict_detection_settings/migration.sql create mode 100644 backend/prisma/migrations/migration_lock.toml create mode 100644 backend/prisma/schema.prisma create mode 100644 backend/scripts/import-missing-videos.mjs create mode 100644 backend/scripts/list-playlist.mjs create mode 100644 backend/scripts/playlist-raw.json create mode 100644 backend/src/.vs/ProjectSettings.json create mode 100644 backend/src/.vs/VSWorkspaceState.json create mode 100644 backend/src/.vs/slnx.sqlite create mode 100644 backend/src/.vs/src/FileContentIndex/d61e9a84-ca45-40f6-9df8-199e679bcccb.vsidx create mode 100644 backend/src/.vs/src/v17/.wsuo create mode 100644 backend/src/.vs/src/v17/DocumentLayout.json create mode 100644 backend/src/app.module.ts create mode 100644 backend/src/health.controller.ts create mode 100644 backend/src/main.ts create mode 100644 backend/src/modules/audit-logs/audit-logs.controller.ts create mode 100644 backend/src/modules/auth/auth.controller.ts create mode 100644 backend/src/modules/auth/auth.module.ts create mode 100644 backend/src/modules/auth/auth.service.ts create mode 100644 backend/src/modules/auth/decorators/roles.decorator.ts create mode 100644 backend/src/modules/auth/guards/jwt-auth.guard.ts create mode 100644 backend/src/modules/auth/guards/roles.guard.ts create mode 100644 backend/src/modules/auth/strategies/google.strategy.ts create mode 100644 backend/src/modules/auth/strategies/jwt.strategy.ts create mode 100644 backend/src/modules/blocks/blocks.controller.ts create mode 100644 backend/src/modules/blocks/blocks.module.ts create mode 100644 backend/src/modules/blocks/blocks.service.ts create mode 100644 backend/src/modules/blocks/dto/create-block.dto.ts create mode 100644 backend/src/modules/blocks/dto/update-block.dto.ts create mode 100644 backend/src/modules/bulk-jobs/bulk-jobs.controller.ts create mode 100644 backend/src/modules/bulk-jobs/bulk-jobs.module.ts create mode 100644 backend/src/modules/bulk-jobs/bulk-jobs.service.ts create mode 100644 backend/src/modules/calendar/calendar.controller.ts create mode 100644 backend/src/modules/calendar/calendar.module.ts create mode 100644 backend/src/modules/calendar/calendar.service.ts create mode 100644 backend/src/modules/campaigns/campaigns.controller.ts create mode 100644 backend/src/modules/campaigns/campaigns.module.ts create mode 100644 backend/src/modules/collaborators/collaborators.controller.ts create mode 100644 backend/src/modules/collaborators/collaborators.module.ts create mode 100644 backend/src/modules/collaborators/collaborators.service.ts create mode 100644 backend/src/modules/collaborators/dto/create-collaborator.dto.ts create mode 100644 backend/src/modules/collaborators/dto/update-collaborator.dto.ts create mode 100644 backend/src/modules/exports/exports.controller.ts create mode 100644 backend/src/modules/exports/exports.module.ts create mode 100644 backend/src/modules/exports/exports.service.ts create mode 100644 backend/src/modules/imports/imports.controller.ts create mode 100644 backend/src/modules/imports/imports.module.ts create mode 100644 backend/src/modules/imports/imports.service.ts create mode 100644 backend/src/modules/linting/linting.controller.ts create mode 100644 backend/src/modules/linting/linting.module.ts create mode 100644 backend/src/modules/linting/linting.service.ts create mode 100644 backend/src/modules/linting/rules/base.rule.ts create mode 100644 backend/src/modules/linting/rules/desc-duplicate-hashtag.rule.ts create mode 100644 backend/src/modules/linting/rules/desc-empty-placeholder.rule.ts create mode 100644 backend/src/modules/linting/rules/desc-missing-chapters.rule.ts create mode 100644 backend/src/modules/linting/rules/desc-missing-cta.rule.ts create mode 100644 backend/src/modules/linting/rules/desc-outdated-sponsor.rule.ts create mode 100644 backend/src/modules/linting/rules/desc-required-link-missing.rule.ts create mode 100644 backend/src/modules/linting/rules/remote-conflict.rule.ts create mode 100644 backend/src/modules/linting/rules/title-too-long.rule.ts create mode 100644 backend/src/modules/linting/rules/title-weak.rule.ts create mode 100644 backend/src/modules/playlists/playlists.controller.ts create mode 100644 backend/src/modules/playlists/playlists.module.ts create mode 100644 backend/src/modules/playlists/playlists.service.ts create mode 100644 backend/src/modules/quota/quota.controller.ts create mode 100644 backend/src/modules/saved-views/saved-views.controller.ts create mode 100644 backend/src/modules/saved-views/saved-views.module.ts create mode 100644 backend/src/modules/saved-views/saved-views.service.ts create mode 100644 backend/src/modules/team-variables/dto/upsert-team-variable.dto.ts create mode 100644 backend/src/modules/team-variables/team-variables.controller.ts create mode 100644 backend/src/modules/team-variables/team-variables.module.ts create mode 100644 backend/src/modules/team-variables/team-variables.service.ts create mode 100644 backend/src/modules/teams/teams.controller.ts create mode 100644 backend/src/modules/teams/teams.module.ts create mode 100644 backend/src/modules/teams/teams.service.ts create mode 100644 backend/src/modules/templates/dto/create-template.dto.ts create mode 100644 backend/src/modules/templates/dto/update-template.dto.ts create mode 100644 backend/src/modules/templates/templates.controller.ts create mode 100644 backend/src/modules/templates/templates.module.ts create mode 100644 backend/src/modules/templates/templates.service.ts create mode 100644 backend/src/modules/video-configs/dto/upsert-video-config.dto.ts create mode 100644 backend/src/modules/video-configs/video-configs.controller.ts create mode 100644 backend/src/modules/video-configs/video-configs.module.ts create mode 100644 backend/src/modules/video-configs/video-configs.service.ts create mode 100644 backend/src/modules/videos/dto/bulk-preview.dto.ts create mode 100644 backend/src/modules/videos/dto/query-videos.dto.ts create mode 100644 backend/src/modules/videos/dto/update-video.dto.ts create mode 100644 backend/src/modules/videos/videos.controller.ts create mode 100644 backend/src/modules/videos/videos.module.ts create mode 100644 backend/src/modules/videos/videos.service.ts create mode 100644 backend/src/modules/youtube-sync/channel-import.service.ts create mode 100644 backend/src/modules/youtube-sync/youtube-api.client.ts create mode 100644 backend/src/modules/youtube-sync/youtube-sync.controller.ts create mode 100644 backend/src/modules/youtube-sync/youtube-sync.module.ts create mode 100644 backend/src/modules/youtube-sync/youtube-sync.service.ts create mode 100644 backend/src/queues/processors/bulk-metadata.processor.ts create mode 100644 backend/src/queues/processors/conflict-detection.processor.ts create mode 100644 backend/src/queues/processors/import.processor.ts create mode 100644 backend/src/queues/processors/lint.processor.ts create mode 100644 backend/src/queues/processors/render.processor.ts create mode 100644 backend/src/queues/processors/youtube-sync.processor.ts create mode 100644 backend/src/queues/queues.constants.ts create mode 100644 backend/src/queues/schedulers/conflict-detection.scheduler.ts create mode 100644 backend/src/shared/audit/audit.module.ts create mode 100644 backend/src/shared/audit/audit.service.ts create mode 100644 backend/src/shared/prisma/prisma.module.ts create mode 100644 backend/src/shared/prisma/prisma.service.ts create mode 100644 backend/src/shared/quota/quota.module.ts create mode 100644 backend/src/shared/quota/quota.service.ts create mode 100644 backend/src/shared/render-engine/hash.ts create mode 100644 backend/src/shared/render-engine/pending-changes.ts create mode 100644 backend/src/shared/render-engine/render-engine.module.ts create mode 100644 backend/src/shared/render-engine/render-engine.service.ts create mode 100644 backend/src/shared/render-engine/video-render.service.ts create mode 100644 backend/src/shared/system-variables/system-variables.controller.ts create mode 100644 backend/src/shared/system-variables/system-variables.module.ts create mode 100644 backend/src/shared/system-variables/system-variables.registry.ts create mode 100644 backend/src/worker.module.ts create mode 100644 backend/src/worker.ts create mode 100644 backend/tsconfig.json create mode 100644 claude.bat create mode 100644 documentation/YouTube Studio Flow/.obsidian/app.json create mode 100644 documentation/YouTube Studio Flow/.obsidian/appearance.json create mode 100644 documentation/YouTube Studio Flow/.obsidian/core-plugins.json create mode 100644 documentation/YouTube Studio Flow/.obsidian/graph.json create mode 100644 documentation/YouTube Studio Flow/00 - Claude Guide.md create mode 100644 documentation/YouTube Studio Flow/00 - Index.md create mode 100644 documentation/YouTube Studio Flow/01 - Architecture/01 - System Overview.md create mode 100644 documentation/YouTube Studio Flow/01 - Architecture/02 - Backend.md create mode 100644 documentation/YouTube Studio Flow/01 - Architecture/03 - Frontend.md create mode 100644 documentation/YouTube Studio Flow/01 - Architecture/04 - Database Schema.md create mode 100644 documentation/YouTube Studio Flow/01 - Architecture/05 - Queue System.md create mode 100644 documentation/YouTube Studio Flow/01 - Architecture/06 - Authentication.md create mode 100644 documentation/YouTube Studio Flow/01 - Architecture/07 - Render Engine.md create mode 100644 documentation/YouTube Studio Flow/02 - Features/01 - Video Management.md create mode 100644 documentation/YouTube Studio Flow/02 - Features/02 - Description Engine.md create mode 100644 documentation/YouTube Studio Flow/02 - Features/03 - Metadata Linting.md create mode 100644 documentation/YouTube Studio Flow/02 - Features/04 - Bulk Operations.md create mode 100644 documentation/YouTube Studio Flow/02 - Features/05 - Playlists.md create mode 100644 documentation/YouTube Studio Flow/02 - Features/06 - Collaborators.md create mode 100644 documentation/YouTube Studio Flow/02 - Features/07 - Saved Views.md create mode 100644 documentation/YouTube Studio Flow/02 - Features/08 - Campaigns.md create mode 100644 documentation/YouTube Studio Flow/02 - Features/09 - Calendar.md create mode 100644 documentation/YouTube Studio Flow/02 - Features/10 - Import Export.md create mode 100644 documentation/YouTube Studio Flow/02 - Features/11 - Quota Management.md create mode 100644 documentation/YouTube Studio Flow/02 - Features/12 - Audit Log.md create mode 100644 documentation/YouTube Studio Flow/02 - Features/13 - Team Settings.md create mode 100644 documentation/YouTube Studio Flow/03 - API Reference/01 - Auth API.md create mode 100644 documentation/YouTube Studio Flow/03 - API Reference/02 - Videos API.md create mode 100644 documentation/YouTube Studio Flow/03 - API Reference/03 - Blocks API.md create mode 100644 documentation/YouTube Studio Flow/03 - API Reference/04 - Templates API.md create mode 100644 documentation/YouTube Studio Flow/03 - API Reference/05 - Collaborators API.md create mode 100644 documentation/YouTube Studio Flow/03 - API Reference/06 - Linting API.md create mode 100644 documentation/YouTube Studio Flow/03 - API Reference/07 - Playlists API.md create mode 100644 documentation/YouTube Studio Flow/03 - API Reference/08 - Saved Views API.md create mode 100644 documentation/YouTube Studio Flow/03 - API Reference/09 - Teams API.md create mode 100644 documentation/YouTube Studio Flow/03 - API Reference/10 - Quota API.md create mode 100644 documentation/YouTube Studio Flow/03 - API Reference/11 - Bulk Jobs API.md create mode 100644 documentation/YouTube Studio Flow/03 - API Reference/12 - Calendar API.md create mode 100644 documentation/YouTube Studio Flow/03 - API Reference/13 - Import Export API.md create mode 100644 documentation/YouTube Studio Flow/03 - API Reference/14 - Team Variables API.md create mode 100644 documentation/YouTube Studio Flow/03 - API Reference/15 - Campaigns API.md create mode 100644 documentation/YouTube Studio Flow/03 - API Reference/16 - YouTube Sync API.md create mode 100644 documentation/YouTube Studio Flow/03 - API Reference/17 - Video Configs API.md create mode 100644 documentation/YouTube Studio Flow/04 - Design Guidelines/01 - Visual Design.md create mode 100644 documentation/YouTube Studio Flow/04 - Design Guidelines/02 - CSS Conventions.md create mode 100644 documentation/YouTube Studio Flow/04 - Design Guidelines/03 - Component Patterns.md create mode 100644 documentation/YouTube Studio Flow/04 - Design Guidelines/04 - Backend Architecture Patterns.md create mode 100644 documentation/YouTube Studio Flow/04 - Design Guidelines/05 - Code Conventions.md create mode 100644 documentation/YouTube Studio Flow/05 - Development/01 - Local Setup.md create mode 100644 documentation/YouTube Studio Flow/05 - Development/02 - Environment Variables.md create mode 100644 documentation/YouTube Studio Flow/05 - Development/03 - Recipes.md create mode 100644 documentation/YouTube Studio Flow/05 - Development/04 - Gotchas.md create mode 100644 documentation/YouTube Studio Flow/05 - Development/05 - Deployment and Operations.md create mode 100644 documentation/YouTube Studio Flow/05 - Development/06 - Verifying Changes.md create mode 100644 documentation/YouTube Studio Flow/06 - Backlog/01 - Technical Debt and Future Work.md create mode 100644 documentation/YouTube Studio Flow/07 - Daily Notes/2026-06-29 01 Scheduled Remote Conflict Detection.md create mode 100644 documentation/YouTube Studio Flow/07 - Daily Notes/2026-07-04 01 Batched Conflict Detection.md create mode 100644 export-data.ps1 create mode 100644 frontend/.dockerignore create mode 100644 frontend/.eslintrc.json create mode 100644 frontend/Dockerfile create mode 100644 frontend/FRONTEND_PLAN.md create mode 100644 frontend/README.md create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.ts create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/app/(dashboard)/DashboardLayout.module.css create mode 100644 frontend/src/app/(dashboard)/audit/page.module.css create mode 100644 frontend/src/app/(dashboard)/audit/page.tsx create mode 100644 frontend/src/app/(dashboard)/blocks/page.module.css create mode 100644 frontend/src/app/(dashboard)/blocks/page.tsx create mode 100644 frontend/src/app/(dashboard)/bulk-jobs/page.module.css create mode 100644 frontend/src/app/(dashboard)/bulk-jobs/page.tsx create mode 100644 frontend/src/app/(dashboard)/calendar/page.module.css create mode 100644 frontend/src/app/(dashboard)/calendar/page.tsx create mode 100644 frontend/src/app/(dashboard)/collaborators/page.module.css create mode 100644 frontend/src/app/(dashboard)/collaborators/page.tsx create mode 100644 frontend/src/app/(dashboard)/layout.tsx create mode 100644 frontend/src/app/(dashboard)/linting/page.module.css create mode 100644 frontend/src/app/(dashboard)/linting/page.tsx create mode 100644 frontend/src/app/(dashboard)/overview/page.module.css create mode 100644 frontend/src/app/(dashboard)/overview/page.tsx create mode 100644 frontend/src/app/(dashboard)/page.module.css create mode 100644 frontend/src/app/(dashboard)/page.tsx create mode 100644 frontend/src/app/(dashboard)/quota-history/page.module.css create mode 100644 frontend/src/app/(dashboard)/quota-history/page.tsx create mode 100644 frontend/src/app/(dashboard)/settings/page.module.css create mode 100644 frontend/src/app/(dashboard)/settings/page.tsx create mode 100644 frontend/src/app/(dashboard)/templates/page.module.css create mode 100644 frontend/src/app/(dashboard)/templates/page.tsx create mode 100644 frontend/src/app/(dashboard)/variables/page.module.css create mode 100644 frontend/src/app/(dashboard)/variables/page.tsx create mode 100644 frontend/src/app/(dashboard)/videos/PushPendingModal.module.css create mode 100644 frontend/src/app/(dashboard)/videos/PushPendingModal.tsx create mode 100644 frontend/src/app/(dashboard)/videos/[id]/page.module.css create mode 100644 frontend/src/app/(dashboard)/videos/[id]/page.tsx create mode 100644 frontend/src/app/(dashboard)/videos/page.module.css create mode 100644 frontend/src/app/(dashboard)/videos/page.tsx create mode 100644 frontend/src/app/auth/callback/CallbackHandler.tsx create mode 100644 frontend/src/app/auth/callback/page.tsx create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/login/Login.module.css create mode 100644 frontend/src/app/login/page.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/components/shared/AuthGuard.tsx create mode 100644 frontend/src/components/shared/BlockOrderEditor.module.css create mode 100644 frontend/src/components/shared/BlockOrderEditor.tsx create mode 100644 frontend/src/components/shared/CollaboratorModal.tsx create mode 100644 frontend/src/components/shared/ColumnPicker.module.css create mode 100644 frontend/src/components/shared/ColumnPicker.tsx create mode 100644 frontend/src/components/shared/FormField.module.css create mode 100644 frontend/src/components/shared/Header.module.css create mode 100644 frontend/src/components/shared/Header.tsx create mode 100644 frontend/src/components/shared/Modal.module.css create mode 100644 frontend/src/components/shared/Modal.tsx create mode 100644 frontend/src/components/shared/Providers.tsx create mode 100644 frontend/src/components/shared/QuotaWidget.module.css create mode 100644 frontend/src/components/shared/QuotaWidget.tsx create mode 100644 frontend/src/components/shared/SavedViewManager.module.css create mode 100644 frontend/src/components/shared/SavedViewManager.tsx create mode 100644 frontend/src/components/shared/Sidebar.module.css create mode 100644 frontend/src/components/shared/Sidebar.tsx create mode 100644 frontend/src/components/shared/SortableSection.module.css create mode 100644 frontend/src/components/shared/SortableSection.tsx create mode 100644 frontend/src/components/shared/UsagePanel.module.css create mode 100644 frontend/src/components/shared/UsagePanel.tsx create mode 100644 frontend/src/components/video-config/VideoConfigEditor.module.css create mode 100644 frontend/src/components/video-config/VideoConfigEditor.tsx create mode 100644 frontend/src/components/video-table/VideoTable.module.css create mode 100644 frontend/src/components/video-table/VideoTable.tsx create mode 100644 frontend/src/hooks/useColumnPreferences.ts create mode 100644 frontend/src/hooks/useTheme.ts create mode 100644 frontend/src/lib/api-client.ts create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/videoFieldOptions.ts create mode 100644 frontend/src/middleware.ts create mode 100644 frontend/src/store/useAuthStore.ts create mode 100644 frontend/src/store/useUIStore.ts create mode 100644 frontend/src/styles/globals.css create mode 100644 frontend/tsconfig.json create mode 100644 import-data.ps1 create mode 100644 infrastructure/.env.example create mode 100644 infrastructure/README.md create mode 100644 infrastructure/docker-compose.yml create mode 100644 setup-dev-environment.ps1 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..03da275 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(winget search *)", + "Bash(docker compose ps *)", + "Bash(docker compose logs *)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c60034 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Dependencies +node_modules/ + +# Build output +backend/dist/ +frontend/.next/ +frontend/tsconfig.tsbuildinfo + +# Logs +*.log + +# Secrets — keep only the .env.example templates tracked +.env +.env.local +.env.*.local +!.env.example + +# Claude Code local overrides (machine-specific, not shared) +.claude/settings.local.json + +# Obsidian local UI state (churns constantly, no value to other devices) +documentation/**/.obsidian/workspace.json +documentation/**/.obsidian/workspace-mobile.json + +# Excluded per project decision: legacy exports / DB backup snapshots +Files/ +data_backup/ + +# OS cruft +Thumbs.db +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e579924 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,22 @@ +# YouTube Studio Flow — Project Context for Claude + +## Documentation (Obsidian Vault) + +The complete project documentation lives in the Obsidian vault at: + +``` +C:/Users/PSaggau.PSLaptop/Documents/Projekte/Youtube_Studio_Flow/documentation/YouTube Studio Flow/ +``` + +Start with `00 - Index.md` for a full navigation overview of all topics. + +| Folder | Contents | +|---|---| +| `01 - Architecture/` | System overview, backend modules, frontend routing, database schema, queue system, auth, render engine | +| `02 - Features/` | Per-feature documentation | +| `03 - API Reference/` | All API endpoints | +| `04 - Design Guidelines/` | CSS conventions, component patterns, backend patterns, code conventions | +| `05 - Development/` | Local setup, environment variables, recipes, gotchas, deployment | +| `06 - Backlog/` | Tech debt and planned features | + +**Read the relevant vault documents before starting any task.** The vault is the authoritative source for architecture decisions, conventions, and known gotchas. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..b6297c2 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +.env +.env.development +.env.production +*.log +.git +coverage diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..6b7a2ea --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,27 @@ +# Database +DATABASE_URL=postgresql://studioflow:change_me@localhost:5432/studioflow + +# Redis +REDIS_URL=redis://:change_me@localhost:6379 + +# JWT +JWT_SECRET=change_me_min_32_chars_xxxxxxxxxxxxxxxxxxx +JWT_REFRESH_SECRET=change_me_min_32_chars_refresh_xxxxxxxxx + +# Google OAuth + YouTube +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret +GOOGLE_CALLBACK_URL=http://localhost:3001/api/v1/auth/google/callback + +# Token encryption (AES-256 for YouTube tokens stored in DB) +TOKEN_ENCRYPTION_KEY=change_me_exactly_32chars_key_xxxx + +# App +PORT=3001 +FRONTEND_URL=http://localhost:3000 +NODE_ENV=development + +# Conflict detection (worker) — global kill switch + cron. +# Per-team toggle and batch limits live in Team settings. +CONFLICT_DETECTION_ENABLED=false +CONFLICT_DETECTION_CRON=0 3 * * * diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..7b46afd --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,32 @@ +FROM node:22-alpine AS builder + +WORKDIR /app +COPY package*.json ./ +COPY prisma ./prisma +RUN npm ci +RUN npx prisma generate +COPY . . +RUN npm run build + +# ───────────────────────────────────────────────────────────────────────────── + +FROM node:22-alpine AS runner + +WORKDIR /app +ENV NODE_ENV=production + +COPY package*.json ./ +COPY prisma ./prisma +RUN npm ci --omit=dev + +# prisma CLI is a devDependency so it's absent after --omit=dev. +# Copy the CLI and the generated client from the builder stage so that +# `npx prisma migrate deploy` works when the migrate service runs this image. +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/prisma ./node_modules/prisma +COPY --from=builder /app/node_modules/.bin/prisma ./node_modules/.bin/prisma + +COPY --from=builder /app/dist ./dist + +EXPOSE 3001 +CMD ["node", "dist/main.js"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..727c9c2 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,33 @@ +# StudioFlow — Backend (API & Worker) + +## Übersicht +Dieses Projekt enthält die gesamte Geschäftslogik, die API-Endpunkte und den Hintergrund-Worker für StudioFlow. Es basiert auf NestJS und nutzt Prisma als ORM. + +## Technologie-Stack +- **Framework:** NestJS 10 (TypeScript) +- **ORM:** Prisma 6 +- **Datenbank:** PostgreSQL 16 +- **Queue:** BullMQ (via Redis 7) +- **Auth:** Passport.js + Google OAuth 2.0 +- **Validierung:** class-validator + zod +- **Dokumentation:** Swagger (@nestjs/swagger) + +## Kernmodule +1. **Render-Engine:** Wandelt Video-Konfigurationen in finale Beschreibungen um. +2. **Quota-Management:** Überwacht und plant YouTube API-Units (Hash-basierter Diff). +3. **Bulk-Jobs:** Verarbeitet Massenänderungen asynchron via BullMQ. +4. **Linting:** Validiert Metadaten gegen vordefinierte Regeln. +5. **Sync-Service:** Kommuniziert mit der YouTube Data API v3. + +## Setup & Start +1. `npm install` +2. Prisma-Client generieren: `npx prisma generate` +3. Server starten: `npm run start:dev` (Main Entry: `src/main.ts`) +4. Worker starten: `npm run start:worker` (Worker Entry: `src/worker.ts`) + +## Verzeichnisstruktur +- `src/main.ts`: Entrypoint für die REST API +- `src/worker.ts`: Entrypoint für den BullMQ Worker +- `src/shared/render-engine/`: Die zentrale Rendering-Logik +- `src/modules/`: Alle fachlichen Module (Videos, Blocks, Templates, etc.) +- `prisma/`: Datenbank-Schema und Migrationen diff --git a/backend/docs/architecture.html b/backend/docs/architecture.html new file mode 100644 index 0000000..2669346 --- /dev/null +++ b/backend/docs/architecture.html @@ -0,0 +1,1133 @@ + + + + + + StudioFlow Backend — Architecture & Code Reference + + + + + + + + +
+ +

StudioFlow Backend

+

NestJS 10 · Prisma 6 · BullMQ · PostgreSQL 16 · Redis 7

+ + +
+

🗺 What is this?

+

+ StudioFlow is an internal YouTube metadata management platform. The backend handles all + business logic: structured description rendering, bulk metadata updates, YouTube API sync + with quota management, metadata linting, and import/export. +

+

+ The codebase is a single NestJS monorepo with two independent entry points — + an HTTP API server and an asynchronous BullMQ worker — sharing all modules and services. +

+ +
+
+

HTTP API (main.ts)

+

REST endpoints, Swagger docs, auth, all CRUD operations. Runs on port 3001.

+
+
+

Worker (worker.ts)

+

BullMQ consumer for async jobs: sync, render, lint, bulk edits, CSV imports.

+
+
+

Render Engine

+

Pure TypeScript — no I/O. Turns a VideoConfig into a final description string.

+
+
+

Quota Guard

+

Tracks YouTube API unit consumption per Pacific-Time day. Enforces 9,000-unit cap.

+
+
+
+ + +
+

🔧 Tech Stack

+
+ + + + + + + + + + + +
LayerLibraryWhy
FrameworkNestJS 10Decorator-driven DI, modular architecture, TypeScript-first
ORMPrisma 6Type-safe queries, migrations, JSONB support
DatabasePostgreSQL 16JSONB for flexible config fields, GIN indexes for query performance
QueueBullMQ + Redis 7Rate limiting, delayed jobs (quota overflow), job retries
AuthPassport + JWT + Google OAuth2YouTube OAuth flow, JWT sessions (15 min), refresh tokens (7 d)
Validationclass-validator + zodRequest DTOs via decorators; zod for CSV row schema validation
Docs@nestjs/swaggerAuto-generated OpenAPI at /api/docs
CSVcsv-parse / csv-stringifySync parsing for import preview; streaming stringify for export
YouTube APIgoogleapisOfficial Google client for videos.list and videos.update
+
+
+ + +
+

📁 File Structure

+

Every folder under src/ has a single, clearly scoped responsibility.

+
+backend/ +├── package.json all dependencies + npm scripts +├── tsconfig.json +├── nest-cli.json dual entry: main + worker +├── .env.example +├── prisma/ +│ └── schema.prisma 17 models, enums, GIN indexes +└── src/ + ├── main.ts HTTP server bootstrap + ├── worker.ts BullMQ worker bootstrap + ├── app.module.ts root module for HTTP + ├── worker.module.ts root module for worker + ├── health.controller.ts GET /health → Docker healthcheck + ├── shared/ cross-cutting, no HTTP endpoints + │ ├── prisma/ PrismaService (global) + │ ├── render-engine/ RenderEngineService + hash.ts + │ ├── quota/ QuotaService — daily unit tracking + │ └── audit/ AuditService — write AuditLog rows + ├── queues/ + │ ├── queues.constants.ts queue name constants + │ └── processors/ + │ ├── youtube-sync.processor.ts + │ ├── bulk-metadata.processor.ts + │ ├── render.processor.ts + │ ├── lint.processor.ts + │ └── import.processor.ts + └── modules/ one folder per domain + ├── auth/ + ├── videos/ + ├── blocks/ + ├── templates/ + ├── video-configs/ + ├── collaborators/ + ├── bulk-jobs/ + ├── saved-views/ + ├── linting/ service + 10 rule files + ├── calendar/ + ├── imports/ + ├── exports/ + ├── youtube-sync/ + ├── quota/ HTTP controller only + └── audit-logs/ HTTP controller only +
+
+ + +
+

🚀 Entry Points

+ +

src/main.ts — HTTP Server

+

Bootstraps the full NestJS application with all feature modules registered.

+
    +
  • Global ValidationPipe with whitelist: true and transform: true — strips unknown fields, auto-converts types
  • +
  • Global prefix /api/v1 on all routes
  • +
  • Swagger UI available at /api/docs (OpenAPI 3)
  • +
  • CORS configured for FRONTEND_URL (default: http://localhost:3000)
  • +
  • Cookie parser for refresh token httpOnly cookies
  • +
+ +

src/worker.ts — BullMQ Worker

+

Creates an ApplicationContext (no HTTP server) using WorkerModule. + Only loads the modules that contain job processors — no controllers are registered.

+ +

src/app.module.ts vs worker.module.ts

+
+ + + + + + + + + + +
ModuleImports
AppModuleAll 13 feature modules + Shared layer + BullModule (Redis) + ConfigModule
WorkerModuleYouTubeSyncModule, BulkJobsModule, LintingModule, ImportsModule + Shared layer + 5 queue registrations + 5 processor providers
+
+
+ + +
+

Shared Layer

+

These modules have no HTTP controllers. They are pure service classes used by feature modules and worker processors alike.

+ +

PrismaService (shared/prisma/)

+

Extends PrismaClient directly. Implements OnModuleInit and OnModuleDestroy to call $connect() and $disconnect(). Registered as a @Global() module so it never needs to be re-imported.

+ +
@Injectable()
+export class PrismaService extends PrismaClient
+  implements OnModuleInit, OnModuleDestroy {
+
+  async onModuleInit()    { await this.$connect(); }
+  async onModuleDestroy() { await this.$disconnect(); }
+}
+ +

AuditService (shared/audit/)

+

Single method log(actorId, entityType, entityId, action, before?, after?). Called by every service that mutates data. Writes to the AuditLog table with full before/after JSON snapshots.

+ +

QuotaService (shared/quota/) — see next section

+

RenderEngineService (shared/render-engine/) — see next section

+
+ + +
+

🖨 Render Engine

+

The render engine is pure TypeScript with zero I/O. It takes all data as input and returns a string. It lives in shared/render-engine/ so both the API (for previews) and the worker (for bulk renders) can use it identically.

+ +

render-engine.service.ts — Pipeline

+
+
Load blockOrder
+
Apply blockOverrides
+
Resolve {variables}
+
Inject collaborators
+
Eval conditionals
+
Expand repeatables
+
Join + SHA-256 hash
+
+ +

Input: RenderInput containing the VideoConfig fields, an array of resolved DescriptionBlock objects, and an array of Collaborator objects.

+

Output: { rendered: string, hash: string }

+ +

Block type handling

+
+ + + + + + + + + +
BlockTypeHow it's rendered
STATICContent is used as-is after variable substitution
VARIABLE{placeholder} tokens replaced from variableValues
CONDITIONALSkipped entirely if its condition variable is falsy ([if:var_name] prefix)
REPEATABLERendered once per item in the relevant array
GLOBALSame as STATIC — content fetched from block library
CAMPAIGNLinked to a Campaign entity; linting checks expiry
COLLABORATORExpanded once per assigned collaborator; injects {collab_name}, {@youtube_handle}, {twitch_link}
+
+ +

hash.ts — SHA-256 metadata hash

+

+ hashMetadata({ title, description, tags, categoryId }) produces a SHA-256 hex string over + JSON.stringify(data). This hash is stored as lastSyncedHash on the Video and + renderHash on VideoConfig. Before any YouTube sync the worker compares the current hash + against the stored one — if identical, zero API units are consumed. +

+ +
export function hashMetadata(data: {
+  title: string; description: string;
+  tags: string[]; categoryId?: string | null;
+}): string {
+  return createHash('sha256')
+    .update(JSON.stringify(data))
+    .digest('hex');
+}
+
+ + +
+

📊 Quota Service

+

YouTube Data API v3 resets at midnight Pacific Time. The quota service enforces a 9,000-unit daily cap (10% reserve below the 10,000 limit).

+ +
+ + + + + + + +
MethodWhat it does
canSpend(units)Returns true if today's usage + units ≤ 9,000
spend(units, op, videoId?)Inserts a QuotaLog row with Pacific Time day key
getTodayUsage()Aggregates SUM(units) for today's PT date
getRemainingToday()9,000 − today's usage
msUntilQuotaReset()Milliseconds until midnight PT — used by worker to delay jobs
+
+ +
+

Unit costs: videos.list costs 1 unit (up to 50 IDs per call). + videos.update costs 50 units regardless of how many fields change. + The hash diff check prevents all writes when nothing has changed.

+
+ +

Tranche scheduling

+

When a Bulk Job would exceed the day's remaining quota, the YouTube Sync processor calls + job.moveToDelayed(msUntilQuotaReset()) to defer those jobs to the next Pacific midnight + automatically — no manual intervention needed.

+
+ + +
+

🔐 Auth Module

+

Authentication uses Google OAuth 2.0 for identity + YouTube token acquisition, then issues short-lived JWTs for API access.

+ +

OAuth Flow

+
+
GET /auth/google
+
Google consent
+
Callback with code
+
Upsert User
+
Issue JWT (15 min)
+
Set refresh cookie (7 d)
+
+ +

Key files

+
+ + + + + + + +
FileResponsibility
strategies/google.strategy.tsPassport strategy — calls authService.upsertGoogleUser() on callback
strategies/jwt.strategy.tsExtracts JWT from Bearer header, validates against DB
guards/jwt-auth.guard.tsApplied globally to all routes that need auth
guards/roles.guard.tsReads @Roles() decorator, compares user role priority
auth.service.tsToken encryption/decryption (AES-256-CBC), JWT issuance, refresh
+
+ +

Token storage security

+

YouTube access and refresh tokens are encrypted with AES-256-CBC before being written to the database. The encryption key comes from TOKEN_ENCRYPTION_KEY env var, derived via scryptSync.

+
+ + +
+

🛡 Roles & Guards

+

Four roles with a priority hierarchy. The RolesGuard checks that the user's priority meets the minimum required for the endpoint.

+
+ + + + + + +
RolePriorityCan
ADMIN4Everything including role management and YouTube sync push
EDITOR3CRUD on all entities, bulk jobs, import/export, render
REVIEWER2Read all data, see lint results
READONLY1GET endpoints only
+
+ +

Sensitive endpoints requiring EDITOR+

+
    +
  • POST /videos/bulk-apply
  • +
  • POST /videos/:id/sync
  • +
  • POST /imports/csv/commit and /imports/json/commit
  • +
  • POST /bulk-jobs/:id/rollback
  • +
+ +

ADMIN-only

+
    +
  • DELETE /saved-views/:id (global views)
  • +
+
+ + +
+

Videos Module

+

Central module. All video list/filter/bulk operations live here.

+ +
+ + + + + + + + + +
MethodPathDescription
GET/videosPaginated list with filters: search, lintStatus, privacyStatus, channelId, templateId, collaboratorId, date range
GET/videos/:idSingle video with config, collaborators, lint results
PATCH/videos/:idUpdate title, tags, privacyStatus, scheduledAt, categoryId, templateId
POST/videos/bulk-previewDry-run — returns before/after diff for each affected video
POST/videos/bulk-applyCreates BulkJob + BulkJobItems, enqueues to bulk-metadata queue
POST/videos/:id/renderSynchronous render — returns rendered text immediately
POST/videos/:id/syncEnqueues a youtube-sync job (async)
+
+ +

Full-text search

+

The search query param builds a Prisma OR condition across title (case-insensitive LIKE) and tags (array contains). No full-text index is required for moderate data volumes.

+ +

Supported bulk action types

+
    +
  • SET_PRIVACY — change privacyStatus for all matched videos
  • +
  • SET_TEMPLATE — assign a template
  • +
  • ADD_TAGS / REMOVE_TAGS — array manipulation
  • +
  • SEARCH_REPLACE_TITLE — string replace in title
  • +
+
+ + +
+

Blocks Module

+

Description blocks are the atomic content units. They are versioned — every update creates an immutable BlockVersion snapshot before applying changes.

+ +
+ + + + + + + +
MethodPathDescription
GET/blocksAll blocks sorted by name
POST/blocksCreate new block
PATCH/blocks/:idUpdate block → auto-creates BlockVersion → increments version
GET/blocks/:id/versionsFull version history (newest first)
GET/blocks/:id/usageWhich VideoConfigs and Templates reference this block
+
+ +

Version snapshot logic

+
// On every PATCH:
+1. Load current block
+2. INSERT BlockVersion { contentSnapshot: { ...currentBlock }, version: block.version }
+3. UPDATE block SET { ...dto, version: version + 1 }
+4. AuditService.log()
+
+ + +
+

Templates Module

+

Templates define a named, reusable block sequence. They also carry default variable values and rule definitions (e.g., required links). Like blocks, templates are versioned.

+ +
+ + + + + + +
MethodPathDescription
GET/templatesAll active templates
POST/templatesCreate template
PATCH/templates/:idUpdate + TemplateVersion snapshot
POST/templates/:id/render-previewRender with sample variable values (no DB write)
+
+ +

Template schema fields (JSONB)

+
    +
  • defaultBlocks — ordered array of block IDs
  • +
  • rules — e.g. { "requiredLinks": ["https://..."] } — used by lint rules
  • +
  • variables — default values for {placeholder} tokens
  • +
+
+ + +
+

🎛 Video Configs Module

+

+ A VideoConfig is the reconstruction key for a video's description. + It stores which blocks are active, in what order, with what variable values and which collaborators — + not the final text. The final text is always reconstructable from this config. +

+ +
+ + + + + +
MethodPathDescription
GET/video-configs/:videoIdGet current config for a video
PUT/video-configs/:videoIdUpsert config (create or replace), increments version
POST/video-configs/:videoId/render-previewPreview render without persisting
+
+ +

Config fields

+
    +
  • blockOrderstring[] — ordered block IDs
  • +
  • blockOverrides{ [blockId]: { content?, active? } } — per-video overrides
  • +
  • variableValues{ [key]: value } — fills {placeholder} tokens
  • +
  • collaboratorIdsstring[] — IDs used for collaborator block expansion
  • +
  • autoRender?: boolean — if true, queues a render job immediately on save
  • +
+
+ + +
+

Collaborators Module

+

Collaborators are entities, not free text. They are referenced by ID in the VideoConfig and resolved at render time into their actual handle, name, and links.

+ +
+ + + + + + + + +
MethodPathDescription
GET/collaboratorsAll collaborators
POST/collaboratorsCreate collaborator
PATCH/collaborators/:idUpdate (archive via active: false)
GET/collaborators/:id/videosAll videos linked to this collaborator
POST/videos/:videoId/collaboratorsLink collaborator to video (upsert)
DELETE/videos/:videoId/collaborators/:cIdRemove link
+
+ +

Placeholder tokens in blocks

+
    +
  • {collab_name}Rhea Vale
  • +
  • {@youtube_handle}@RheaPlays
  • +
  • {twitch_link}https://twitch.tv/rheavale
  • +
+
+ + +
+

Linting Module

+

Ten pluggable lint rules. Each is a class implementing LintRule. New rules can be added without touching existing code.

+ +

LintRule interface

+
interface LintRule {
+  code: string;
+  severity: LintSeverity; // INFO | WARNING | ERROR
+  check(video: any): LintIssue | null;
+}
+ +

All 10 rules

+
+ + + + + + + + + + + + +
CodeSeverityWhat it checks
TITLE_WEAKWARNINGTitle shorter than 20 chars or contains generic words (video, test, untitled)
TITLE_TOO_LONGWARNINGTitle exceeds 100 characters
DESC_MISSING_CTAWARNINGNo subscribe/follow/like keywords in rendered description
DESC_MISSING_CHAPTERSWARNINGFewer than 2 timestamp patterns (e.g. 0:00) in description
DESC_EMPTY_PLACEHOLDERERRORUnresolved {variable} tokens remain in the rendered text
DESC_DUPLICATE_HASHTAGWARNINGDuplicate #hashtag tokens in description
DESC_REQUIRED_LINK_MISSINGERRORTemplate rules.requiredLinks contains a URL not present in description
DESC_OUTDATED_SPONSOR_COPYERRORA campaign block in the config references a campaign that has expired
COLLAB_REFERENCE_INVALIDERRORA collaborator ID in videoConfig.collaboratorIds has no corresponding DB record
REMOTE_CONFLICTERRORvideo.remoteConflict === true — YouTube was edited outside StudioFlow
+
+ +

LintingService.lintVideo(videoId)

+
1. Load video with config + template + collaborators
+2. Load campaign blocks for expiry check
+3. Run all 10 rules → collect non-null LintIssue results
+4. DELETE existing unresolved LintResults for this video
+5. INSERT new LintResult rows
+6. Compute overall lintStatus: ERROR > WARNING > OK
+7. UPDATE video.lintStatus
+ +
+ + + + + +
MethodPathDescription
POST/lint/videos/:idLint one video synchronously
POST/lint/bulkEnqueue lint jobs for array of video IDs
GET/lint/resultsAll open lint results, filterable by severity/ruleCode/videoId
+
+
+ + +
+

Bulk Jobs Module

+

Bulk jobs follow a dry-run → confirm → execute → rollback lifecycle.

+ +

Lifecycle

+
+
PENDING (dry-run)
+
DRY_RUN (diff shown)
+
CONFIRMED
+
RUNNING
+
DONE / FAILED
+
+

Each BulkJob has one BulkJobItem per affected video. The item stores the before snapshot and after snapshot so rollback is a simple record-by-record restore.

+ +
+ + + + + +
MethodPathDescription
GET/bulk-jobsList all bulk jobs (filter by status)
GET/bulk-jobs/:idSingle job with all items and video titles
POST/bulk-jobs/:id/rollbackRestore all beforeSnapshot values; sets status to ROLLED_BACK
+
+ +
+

Rollback safety: Only items with status: 'done' are restored. Items that failed are skipped. The rollback itself is audited via AuditService.

+
+
+ + +
+

Saved Views Module

+

Saved views are reusable filter presets stored as JSON. They can be applied to the video table, used as a scope for exports, or fed into bulk jobs as a video selector.

+ +

4 default views (seeded on startup)

+
    +
  • All sponsor videos — tags contain "sponsor"
  • +
  • Needs chapters — lintStatus WARNING
  • +
  • Missing CTA — lintStatus WARNING
  • +
  • Published this month — publishedAt ≥ first day of current month (dynamic placeholder resolved at execute time)
  • +
+ +
+ + + + + + + +
MethodPathDescription
GET/saved-viewsAll views
POST/saved-viewsCreate (sets ownerId from JWT)
PATCH/saved-views/:idUpdate
DELETE/saved-views/:idDelete (ADMIN only)
POST/saved-views/:id/executeRun queryJson against DB, return matching video IDs + count
+
+
+ + +
+

📅 Calendar Module

+

Returns calendar-shaped entries for scheduled and published videos. Frontend can use these to render month/week/agenda views.

+ +
+ + + + + +
View paramDate range
monthFull calendar month from the given YYYY-MM
week7-day week starting from the Sunday of the given date
agenda30 days forward from the given date
+
+ +

Each calendar entry shape: { videoId, title, date, templateName, collaborators[], lintStatus, channelId, privacyStatus }

+
+ + +
+

↑↓ Import / Export

+ +

CSV Import

+

Supports these columns: youtube_video_id, title, tags, category_id, privacy_status, scheduled_at, template_name, collaborators.

+ +
+
Upload file (multipart)
+
POST /imports/csv/preview
+
Validation report per row
+
POST /imports/csv/commit
+
Worker executes
+
+ +

Each row is validated with a Zod schema. Errors are returned as [{ row, field, message }]. An ImportJob record is created at preview time to track the state.

+ +

JSON Import/Export

+

Full workspace format covers all entity types:

+
{
+  "version": "1.0",
+  "exportedAt": "2026-04-27T...",
+  "videos": [...],
+  "videoConfigs": [...],
+  "blocks": [...],
+  "templates": [...],
+  "collaborators": [...],
+  "savedViews": [...]
+}
+ +

Import upserts all entities by ID, so re-importing a backup is idempotent.

+ +

CSV Export

+

Accepts { videoIds?, savedViewId? }. If neither is provided, exports all videos. Returns a streamed text/csv response with a Content-Disposition: attachment header.

+
+ + +
+

🔴 YouTube Sync Module

+

Wraps the googleapis YouTube Data API v3 client. The YouTubeApiClient handles OAuth token decryption and the YouTubeSyncService adds quota accounting and conflict detection.

+ +

YouTubeApiClient

+
    +
  • getVideoMetadata(youtubeVideoId)videos.list, part: snippet + status, cost: 1 unit
  • +
  • updateVideoMetadata(id, meta)videos.update, part: snippet + status, cost: 50 units
  • +
+ +

YouTubeSyncService.detectConflict(videoId)

+

Fetches the current YouTube snippet, hashes it, and compares with lastSyncedHash. If they differ, sets video.remoteConflict = true — which will be caught by the REMOTE_CONFLICT lint rule on the next lint run.

+ +

YouTube Sync Worker (processor)

+
1. Load video + config from DB
+2. Render description with RenderEngineService
+3. Hash new metadata → compare with lastSyncedHash
+   → identical? SKIP (0 units spent)
+4. quota.canSpend(50)?
+   → no? moveToDelayed(msUntilQuotaReset())
+5. ytSync.pushUpdate(video, meta)
+6. quota.spend(50, 'videos.update', videoId)
+7. UPDATE video.lastSyncedHash + lastSyncedAt
+ +
+

Rate limiter: The processor is configured with limiter: { max: 5, duration: 10_000 } — maximum 5 YouTube API calls per 10 seconds — and concurrency: 1 to prevent race conditions on quota tracking.

+
+
+ + +
+

BullMQ Queues & Processors

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
QueueProcessorJob dataWhat it does
youtube-syncYouTubeSyncProcessor{ videoId }Render → hash diff → quota check → YouTube update
bulk-metadataBulkMetadataProcessor{ bulkJobId, itemId }Apply one BulkJobItem action, update success/error counts, mark job DONE when all items complete
renderRenderProcessor{ videoId }Render description, save to DB, then enqueue a lint job
lintLintProcessor{ videoId }Run all 10 lint rules, persist results
importImportProcessor{ importJobId }Execute committed ImportJob (upsert rows to DB)
+
+ +

Default job options (bulk-metadata)

+
{
+  attempts: 3,
+  backoff: { type: 'exponential', delay: 2000 },
+  removeOnComplete: { age: 7 * 24 * 60 * 60 }, // keep 7 days
+  removeOnFail: false                              // keep failed jobs for analysis
+}
+ +

Chained jobs: Render → Lint

+

When the render processor finishes, it immediately enqueues a lint job so the lint status is always fresh after any render. This means a PUT /video-configs/:videoId with autoRender: true triggers: save config → render job → lint job.

+
+ + +
+

💹 Quota & Audit Log API

+ +

Quota endpoint

+
+ + + +
MethodPathResponse
GET/quota/today{ used, remaining, limit: 9000, resetAt, percentUsed }
+
+ +

Audit log endpoints

+
+ + + + +
MethodPathDescription
GET/audit-logsPaginated full audit trail (newest first)
GET/audit-logs/:entityType/:entityIdAll audit events for a specific entity (e.g. /audit-logs/Video/clxyz123)
+
+ +

Every mutation that goes through a service method calls AuditService.log() with a full before/after JSON snapshot. This gives a complete change history for any entity.

+
+ + +
+

🔑 Environment Variables

+
+ + + + + + + + + + + + + + +
VariableRequiredDescription
DATABASE_URLPostgreSQL connection string
REDIS_URLRedis connection string (with password)
JWT_SECRETSecret for access JWTs (min 32 chars)
JWT_REFRESH_SECRETSeparate secret for refresh tokens
GOOGLE_CLIENT_IDGoogle OAuth app client ID
GOOGLE_CLIENT_SECRETGoogle OAuth app client secret
GOOGLE_CALLBACK_URLFull callback URL e.g. http://localhost:3001/api/v1/auth/google/callback
YOUTUBE_API_KEYYouTube Data API v3 key (for public read ops)
TOKEN_ENCRYPTION_KEY32-char key for AES-256-CBC encryption of stored OAuth tokens
PORTAPI port, default 3001
FRONTEND_URLCORS origin, default http://localhost:3000
NODE_ENVdevelopment / production
+
+
+ + +
+

Getting Started

+ +

1. Install dependencies

+
cd backend
+npm install
+ +

2. Set up environment

+
cp .env.example .env
+# Fill in all required values in .env
+ +

3. Generate Prisma client

+
npx prisma generate
+ +

4. Run migrations (requires running PostgreSQL)

+
npx prisma migrate dev --name init
+ +

5. Start the API server

+
npm run start:dev
+# → http://localhost:3001/api/v1
+# → http://localhost:3001/api/docs  (Swagger UI)
+# → http://localhost:3001/health    (Docker healthcheck)
+ +

6. Start the worker (separate terminal)

+
npm run start:worker
+ +

Via Docker Compose

+
# First-time migration:
+docker compose --profile tools run --rm migrate
+
+# Start all services:
+docker compose up -d
+ +
+

Note: The worker and API use the same Docker image but different start commands. + The worker runs node dist/worker.js instead of the default node dist/main.js.

+
+ +
+ +

MVP Phase summary

+
+ + + + + + + + + + + + + + + + + +
PhaseScopeKey deliverable
Phase 1Structured coreVideo config editor, block library, templates, collaborators, render + preview
Phase 2OperationsBulk jobs with dry-run/rollback, linting, YouTube sync, quota management
Phase 3Extended featuresContent calendar, CSV/JSON import-export, campaigns, quota history
+
+
+ +
+ + + + diff --git a/backend/docs/guide-part1.html b/backend/docs/guide-part1.html new file mode 100644 index 0000000..82eea09 --- /dev/null +++ b/backend/docs/guide-part1.html @@ -0,0 +1,883 @@ + + + + + +StudioFlow Backend — Developer Guide (Part 1 of 3) + + + + + + +
+ +

StudioFlow Backend

+

Developer Guide — Part 1 of 3

+

Project Overview · TypeScript · NestJS Core Concepts

+ + +

What Is This Project

+ +

StudioFlow is a YouTube content management system for creators who manage large video libraries. Instead of editing every video description by hand on YouTube's website, StudioFlow lets you build reusable description blocks, combine them into templates, attach them to individual videos, and push updates to YouTube's API in bulk — all while enforcing quality rules through an automated linting system.

+ +

The backend you are reading about is the server-side application — it handles:

+ + + + + + + + + + + + + +
ResponsibilityHow it works
User authenticationGoogle OAuth 2.0 — users log in with their Google/YouTube account
Data storagePostgreSQL database accessed through the Prisma ORM
Description renderingA pure TypeScript engine that assembles blocks into final text
Quality checks10 pluggable lint rules run after every render
YouTube syncPushes rendered descriptions to YouTube via their Data API v3
Background jobsBullMQ queues on Redis handle long-running work asynchronously
Bulk operationsApply changes to hundreds of videos at once, with rollback support
Import / exportCSV and JSON workspace snapshots
+ + +

Technology Stack

+ + + + + + + + + + + + + + + + +
TechnologyVersionRole
Node.js≥ 20JavaScript runtime — executes the compiled TypeScript
TypeScript5.xAdds static types to JavaScript; compiled to plain JS for Node.js to run
NestJS11.xApplication framework — organises the code into modules, controllers, services
PostgreSQL16Relational database — stores all persistent data
Prisma6.xORM (Object-Relational Mapper) — TypeScript-first database client
Redis7In-memory data store — used as the BullMQ queue backend
BullMQ5.xQueue library — runs background jobs (sync, render, lint, import)
Passport.js0.7Authentication middleware — handles Google OAuth and JWT strategies
googleapis171.xGoogle's official Node.js SDK — calls the YouTube Data API v3
Zod3.xSchema validation — validates CSV import rows at runtime
class-validator0.14Decorator-based validation — validates HTTP request bodies via NestJS pipes
+ + +

File Structure

+ +
+backend/
+├── prisma/
+│ └── schema.prisma ← database schema (tables, enums, relations)
+├── src/
+│ ├── main.ts ← HTTP server entry point
+│ ├── worker.ts ← background worker entry point
+│ ├── app.module.ts ← root module (wires everything together)
+│ ├── worker.module.ts ← root module for the worker process
+│ ├── health.controller.ts ← GET /health endpoint
+│ │
+│ ├── shared/ ← cross-cutting services used by many modules
+│ │ ├── prisma/ ← database connection (PrismaService)
+│ │ ├── render-engine/ ← description assembly engine
+│ │ ├── quota/ ← YouTube API quota tracker
+│ │ └── audit/ ← audit log writer
+│ │
+│ ├── queues/
+│ │ ├── queues.constants.ts ← queue name strings
+│ │ └── processors/ ← background job handlers (one file per queue)
+│ │
+│ └── modules/ ← feature modules (one folder per domain)
+│ ├── auth/ ← Google OAuth + JWT
+│ ├── videos/ ← video CRUD, render, sync
+│ ├── blocks/ ← description block management
+│ ├── templates/ ← template management
+│ ├── video-configs/ ← per-video configuration
+│ ├── collaborators/ ← collaborator management
+│ ├── linting/ ← 10 lint rules + service
+│ ├── bulk-jobs/ ← bulk operation tracking
+│ ├── saved-views/ ← saved filter presets
+│ ├── calendar/ ← calendar view endpoint
+│ ├── imports/ ← CSV + JSON import
+│ ├── exports/ ← CSV + JSON export
+│ ├── youtube-sync/ ← YouTube API wrapper
+│ ├── quota/ ← HTTP quota status endpoint
+│ └── audit-logs/ ← HTTP audit log endpoints
+
+ + +

Two Entry Points

+ +

One of the most important architectural decisions in this project: the backend runs as two separate processes.

+ + + + + + + +
ProcessFilePortDoes
API serversrc/main.ts3001Handles HTTP requests from the frontend. Returns JSON.
Workersrc/worker.tsnoneListens to Redis queues and processes background jobs.
+ +

Why split them? Background jobs (rendering hundreds of descriptions, syncing to YouTube) can take seconds or minutes. If they ran in the same process as the HTTP server, they would block responses. By separating them, the API server stays fast and responsive while the worker does the heavy work in the background.

+ +
// src/main.ts — HTTP API server
+async function bootstrap() {
+  const app = await NestFactory.create(AppModule);   // creates a full HTTP server
+
+  app.use(cookieParser());           // parse cookies (for refresh tokens)
+  app.enableCors({ origin: 'http://localhost:3000', credentials: true });
+  app.setGlobalPrefix('api/v1');   // all routes become /api/v1/...
+  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
+  await app.listen(3001);
+}
+
+// src/worker.ts — background worker (no HTTP)
+async function bootstrap() {
+  const app = await NestFactory.createApplicationContext(WorkerModule);
+  app.enableShutdownHooks();        // graceful shutdown on SIGTERM
+}
+ +
+ Key concept + NestFactory.create() starts an HTTP server. NestFactory.createApplicationContext() starts the NestJS dependency injection container without any HTTP server — just the services and queue processors. +
+ + +

Why TypeScript

+ +

JavaScript is dynamically typed — you can write let x = 5; x = "hello"; and it will just work. This is convenient but causes bugs that only appear at runtime.

+ +

TypeScript adds a type system on top of JavaScript. The TypeScript compiler checks your code before it runs and catches entire categories of bugs at compile time. When you run npm run build, the TypeScript compiler (tsc) reads all .ts files, checks the types, and outputs plain .js files that Node.js actually executes.

+ +
// JavaScript: this fails silently at runtime
+function greet(user) {
+  return `Hello, ${user.name}`;   // crashes if user is undefined
+}
+
+// TypeScript: the compiler warns you before it runs
+function greet(user: { name: string }): string {
+  return `Hello, ${user.name}`;   // ✓ safe — TypeScript knows name is a string
+}
+
+greet(undefined);  // ✗ ERROR at compile time: Argument of type 'undefined' is
+                   //   not assignable to parameter of type '{ name: string }'
+ + +

Types & Interfaces

+ +

TypeScript gives you several ways to describe the shape of data.

+ +

Primitive types

+
let name: string = "Alice";
+let count: number = 42;
+let active: boolean = true;
+let data: unknown = fetchSomething();  // type unknown until you narrow it
+let anything: any = legacyFunction();  // escapes type checking — use sparingly
+ +

Interfaces — describing object shapes

+

An interface is a contract. It says "any object that claims to be this type must have these properties." Interfaces exist only in TypeScript — they disappear completely in the compiled JavaScript.

+ +
// From src/shared/render-engine/render-engine.service.ts
+interface RenderBlock {
+  id: string;
+  type: BlockType;      // BlockType is a Prisma-generated enum
+  content: string;
+  active: boolean;
+  campaignId?: string | null;  // ? = optional property
+}
+
+interface RenderResult {
+  rendered: string;
+  hash: string;
+}
+ +
+ The ? and | null pattern + In TypeScript with strictNullChecks: true (which this project uses), string means the value is definitely a string — it can never be null or undefined. If null is possible, you must explicitly write string | null or string? (shorthand for string | undefined). +
+ +

Union types

+
// A value can be one of several specific types
+type Order = 'asc' | 'desc';                     // only these two strings
+type MaybeString = string | null | undefined;  // string or absent
+
+// Used in query-videos.dto.ts:
+order?: 'asc' | 'desc' = 'desc';   // optional, defaults to 'desc'
+ +

Type aliases

+
// Type aliases give a name to any type expression
+type VideoId = string;                              // just a named string
+type BulkAction = 'SET_PRIVACY' | 'ADD_TAGS' | 'SET_TEMPLATE';
+
+// Record<K, V> — an object whose keys are K and values are V
+const overrides: Record<string, { content?: string; active?: boolean }> = {};
+ + +

Classes & OOP

+ +

TypeScript classes are the backbone of NestJS. They combine data (properties) and behaviour (methods) in one place, and the type system understands them fully.

+ +

Class fundamentals

+
// A class defines a blueprint; instances are created with `new`
+class QuotaService {
+  // private = only accessible inside this class
+  private readonly DAILY_LIMIT = 9_000;
+
+  // constructor = runs when you do `new QuotaService(prisma)`
+  constructor(private readonly prisma: PrismaService) {}
+  //           ↑ TypeScript shorthand: declares AND assigns this.prisma in one step
+
+  // async method — returns a Promise
+  async canSpend(units: number): Promise<boolean> {
+    const used = await this.getTodayUsage();
+    return used + units <= this.DAILY_LIMIT;
+  }
+}
+ +

extends — inheritance

+

extends means "this class IS a kind of that class — it inherits all its methods and properties."

+ +
// src/shared/prisma/prisma.service.ts
+class PrismaService extends PrismaClient
+    implements OnModuleInit, OnModuleDestroy {
+
+  // PrismaService IS a PrismaClient — it has all its database methods
+  // PLUS it adds NestJS lifecycle hooks
+
+  async onModuleInit() {
+    await this.$connect();    // inherited from PrismaClient
+  }
+  async onModuleDestroy() {
+    await this.$disconnect();
+  }
+}
+ +

implements — contracts

+

implements says "this class promises to have all the methods that this interface requires." The TypeScript compiler will error if any method is missing.

+ +
// src/modules/linting/rules/base.rule.ts
+interface LintRule {
+  code: string;
+  severity: LintSeverity;
+  check(video: any): LintIssue | null;  // return null if no issue
+}
+
+// Every rule class must implement this interface
+class TitleWeakRule implements LintRule {
+  code = 'TITLE_WEAK';
+  severity = LintSeverity.WARNING;
+
+  check(video: any): LintIssue | null {
+    if (video.title.length < 20) {
+      return { message: 'Title too short', targetField: 'title' };
+    }
+    return null;
+  }
+}
+ +
+ Interface vs Class + An interface is only a compile-time contract — it generates zero JavaScript. A class generates real JavaScript code (a constructor function) that creates objects at runtime. Use interfaces to describe shapes you don't own; use classes for things you instantiate. +
+ + +

Generics

+ +

Generics let you write code that works with any type while still being type-safe. Think of <T> as a type variable — a placeholder you fill in when you actually use the function or class.

+ +
// Without generics — not type-safe, returns `any`
+function first(arr: any[]): any {
+  return arr[0];
+}
+
+// With generics — type-safe, TypeScript knows the return type
+function first<T>(arr: T[]): T {
+  return arr[0];
+}
+
+const num = first([1, 2, 3]);     // TypeScript infers: num is number
+const str = first(['a', 'b']);   // TypeScript infers: str is string
+ +

Generics in this project

+
// Promise<T> — a future value of type T
+async canSpend(units: number): Promise<boolean>  // will resolve to a boolean
+async findOne(id: string): Promise<Video>       // will resolve to a Video
+
+// Record<K, V> — object with keys of type K and values of type V
+blockOverrides: Record<string, { content?: string; active?: boolean }>
+
+// Map<K, V> — JavaScript Map with typed keys and values
+const blockMap = new Map<string, RenderBlock>(
+  blocks.map((b) => [b.id, b])
+);
+
+// BullMQ Job<T> — a queue job whose data payload is of type T
+async process(job: Job<{ videoId: string }>) {
+  const { videoId } = job.data;   // TypeScript knows this is a string
+}
+ + +

Decorators

+ +

Decorators are the most important TypeScript feature to understand for NestJS. They are functions that annotate classes, methods, or properties — they run at startup and attach metadata or modify behaviour.

+ +

In TypeScript, a decorator is written with an @ prefix directly above what it decorates.

+ +
// A decorator is just a function that receives the target it decorates
+function Injectable() {
+  return function(target: any) {
+    // Reflect.metadata stores information about the class
+    Reflect.defineMetadata('injectable', true, target);
+  };
+}
+
+// Usage — NestJS reads this metadata to know it can inject this class
+@Injectable()
+class QuotaService { ... }
+
+ +

How NestJS uses decorators

+
// CONTROLLER DECORATOR — marks this class as an HTTP controller
+// and sets the route prefix to /videos
+@Controller('videos')
+export class VideosController {
+
+  // METHOD DECORATOR — this method handles GET /videos
+  @Get()
+  findAll() { ... }
+
+  // PARAM DECORATOR — extracts :id from the URL path
+  @Get(':id')
+  findOne(@Param('id') id: string) { ... }
+
+  // QUERY DECORATOR — extracts ?search=... from the URL
+  @Get()
+  search(@Query() query: QueryVideosDto) { ... }
+
+  // BODY DECORATOR — extracts the JSON request body
+  @Post()
+  create(@Body() dto: CreateBlockDto) { ... }
+
+  // REQ DECORATOR — injects the full Express request object
+  @Get('me')
+  me(@Req() req: Request) {
+    return req.user;   // attached by JwtStrategy
+  }
+}
+ +

Stacking decorators

+
// Multiple decorators are applied bottom-up (closest to the function first)
+@UseGuards(JwtAuthGuard, RolesGuard)  // applied second
+@Controller('videos')               // applied first
+export class VideosController { ... }
+
+// On a method, decorators describe what middleware runs and what Swagger shows
+@Patch(':id')
+@Roles(UserRole.EDITOR)            // custom decorator — attaches metadata
+@ApiOperation({ summary: 'Update video fields' })
+update(@Param('id') id: string) { ... }
+ +
+ Requires tsconfig flags + Decorators require "experimentalDecorators": true and "emitDecoratorMetadata": true in tsconfig.json. The project already has both set. Without them, NestJS's dependency injection system cannot function. +
+ + +

async / await

+ +

Almost every database call and HTTP request in this project is asynchronous — it takes time and the Node.js runtime should not sit idle waiting. JavaScript handles this with Promises and the async/await syntax.

+ +
// A Promise is a value that will be available in the future
+const p: Promise<Video> = prisma.video.findFirst(...);
+// p.then(video => ...) — the old way to get the value
+
+// async/await — the modern, readable way
+async function getVideo(id: string): Promise<Video> {
+  // await pauses this function until the Promise resolves
+  // but does NOT block other requests — Node.js handles other work meanwhile
+  const video = await prisma.video.findUnique({ where: { id } });
+  if (!video) throw new NotFoundException();
+  return video;
+}
+
+// Run two queries in parallel — much faster than sequential awaits
+const [total, items] = await Promise.all([
+  prisma.video.count({ where }),
+  prisma.video.findMany({ where, skip, take }),
+]);
+// Both queries execute simultaneously; we wait for BOTH to finish
+ +
+ Promise.all for parallel queries + This pattern appears throughout the codebase (e.g., in VideosService.findAll()). When two operations don't depend on each other, running them in parallel roughly halves the wait time. +
+ + +

Enums

+ +

An enum is a set of named constants. Prisma generates TypeScript enums from schema.prisma automatically — you import them and use them instead of raw strings.

+ +
// In schema.prisma:
+// enum UserRole { ADMIN EDITOR REVIEWER READONLY }
+
+// Prisma generates this TypeScript enum:
+enum UserRole {
+  ADMIN    = 'ADMIN',
+  EDITOR   = 'EDITOR',
+  REVIEWER = 'REVIEWER',
+  READONLY = 'READONLY',
+}
+
+// Usage — much safer than raw strings
+@Roles(UserRole.EDITOR)    // ✓ compiler checks this is a valid role
+@Roles('ediotr')          // ✗ typo would cause a runtime bug, not a compile error
+
+// The RolesGuard maps roles to priority numbers
+const ROLE_PRIORITY: Record<UserRole, number> = {
+  [UserRole.ADMIN]:    4,
+  [UserRole.EDITOR]:   3,
+  [UserRole.REVIEWER]: 2,
+  [UserRole.READONLY]: 1,
+};
+ + +

Utility Types

+ +

TypeScript ships with built-in generic "utility types" that transform existing types into new ones. This project uses several of them via Prisma's generated types.

+ +
// Partial<T> — makes all properties of T optional
+// Used in UpdateVideoDto: you only need to provide fields you want to change
+type UpdateVideoDto = Partial<{ title: string; privacyStatus: PrivacyStatus }>;
+
+// Omit<T, K> — removes keys K from type T
+// Prisma uses this to create "CreateInput" vs "UpdateInput" types
+type CreateInput = Omit<Video, 'id' | 'createdAt' | 'updatedAt'>;
+
+// Pick<T, K> — keeps only keys K from type T
+prisma.video.findMany({
+  select: { id: true, title: true }  // returns Pick<Video, 'id' | 'title'>
+});
+
+// ReturnType<T> — the type that function T returns
+type ServiceResult = ReturnType<typeof videosService.findAll>;
+ + +

What Is NestJS

+ +

NestJS is an opinionated framework for building server-side applications with TypeScript. "Opinionated" means it makes strong decisions about how to structure your code — where files go, how services talk to each other, how requests get validated — so you don't have to reinvent that every project.

+ +

NestJS is built on top of Express.js (the traditional Node.js HTTP library) and adds:

+ + + +

Modules

+ +

A module is a cohesive unit of functionality. Every NestJS application has at least one module (the root module). Modules declare which controllers and services they contain, and which other modules they depend on.

+ +
// A minimal module — declares its own parts and exports PrismaService
+// so other modules can use it without re-declaring it
+@Global()    // makes this module's exports available everywhere without importing
+@Module({
+  providers: [PrismaService],   // services this module creates and manages
+  exports: [PrismaService],     // services this module shares with others
+})
+export class PrismaModule {}
+
+// A feature module — imports what it needs, declares its own parts
+@Module({
+  imports: [QuotaModule, AuthModule],        // other modules whose exports we need
+  providers: [YouTubeSyncService, YouTubeApiClient],
+  exports: [YouTubeSyncService, YouTubeApiClient],  // share with other modules
+})
+export class YouTubeSyncModule {}
+ +
+ The module graph + NestJS builds a directed graph of all modules at startup. A module can only use (inject) services that it declared in providers or that are exported by modules it listed in imports. This is what prevents spaghetti dependencies. +
+ +

The root AppModule

+

The AppModule imports every feature module. It is the entry point of the module graph — NestJS walks it to discover everything the application needs.

+ +
@Module({
+  imports: [
+    ConfigModule.forRoot({ isGlobal: true }),     // reads .env file
+    BullModule.forRootAsync({ ... }),              // Redis connection
+    PrismaModule, RenderEngineModule, QuotaModule,  // shared layer
+    AuthModule, VideosModule, BlocksModule,          // feature modules
+    // ... all other modules
+  ],
+  controllers: [HealthController, QuotaController, AuditLogsController],
+})
+export class AppModule {}
+ + +

Dependency Injection

+ +

Dependency Injection (DI) is the mechanism that connects services together. Instead of a service creating its own dependencies with new, NestJS creates them and injects them through the constructor.

+ +
// WITHOUT dependency injection — tightly coupled, hard to test
+class VideosService {
+  private prisma = new PrismaService();    // creates its own instance
+  private audit = new AuditService();     // creates its own instance
+}
+
+// WITH dependency injection — NestJS handles creation and sharing
+@Injectable()
+class VideosService {
+  constructor(
+    private readonly prisma: PrismaService,  // NestJS provides the singleton
+    private readonly audit: AuditService,   // same instance used everywhere
+    private readonly renderEngine: RenderEngineService,
+    @InjectQueue(QUEUES.YOUTUBE_SYNC) private readonly syncQueue: Queue,
+    // ↑ special injection for BullMQ queues — uses a token, not a class name
+  ) {}
+}
+ +

NestJS reads the type annotations on the constructor parameters (thanks to emitDecoratorMetadata in tsconfig) and knows exactly which singleton to inject. The @Injectable() decorator marks a class as something NestJS can manage.

+ + +

Controllers

+ +

A controller maps HTTP routes to service methods. It handles request parsing and response formatting, but contains no business logic — it delegates to services for that.

+ +
@ApiTags('videos')          // Swagger grouping
+@ApiBearerAuth()            // Swagger shows lock icon on these endpoints
+@UseGuards(JwtAuthGuard, RolesGuard)  // ALL routes require JWT + role check
+@Controller('videos')       // prefix: /api/v1/videos
+export class VideosController {
+  constructor(private readonly service: VideosService) {}
+
+  @Get()
+  findAll(@Query() query: QueryVideosDto) {
+    return this.service.findAll(query);
+    // NestJS automatically serializes the returned object to JSON
+  }
+
+  @Patch(':id')
+  @Roles(UserRole.EDITOR)         // additionally require EDITOR role
+  update(
+    @Param('id') id: string,    // from URL path
+    @Body() dto: UpdateVideoDto, // from request body (validated by pipe)
+    @Req() req: any,             // full request — we need req.user.id
+  ) {
+    return this.service.update(id, dto, req.user.id);
+  }
+}
+ + +

Providers & Services

+ +

Any class decorated with @Injectable() is a provider. Services are the most common kind of provider — they contain the business logic.

+ +

By default, NestJS creates providers as singletons — one instance per module. The same PrismaService instance is shared by every service that injects it. This is efficient and means database connection pooling works correctly.

+ +
@Injectable()   // ← this is what makes it a provider
+export class AuditService {
+  constructor(private readonly prisma: PrismaService) {}
+
+  async log(
+    actorId: string,
+    entityType: string,
+    entityId: string,
+    action: string,
+    before?: object | null,
+    after?: object | null,
+  ): Promise<void> {
+    await this.prisma.auditLog.create({
+      data: { actorId, entityType, entityId, action, beforeJson: before, afterJson: after },
+    });
+  }
+}
+ + +

Guards

+ +

A guard is a class that implements CanActivate. It runs before a controller method and decides whether the request should proceed. If it returns false (or throws), NestJS returns 403 Forbidden.

+ +

JwtAuthGuard — verifies the JWT token

+
// src/modules/auth/guards/jwt-auth.guard.ts
+@Injectable()
+export class JwtAuthGuard extends AuthGuard('jwt') {}
+// That's it — Passport's AuthGuard does the heavy lifting:
+// 1. Extracts Bearer token from the Authorization header
+// 2. Verifies signature using JWT_SECRET
+// 3. Calls JwtStrategy.validate() to load the user from DB
+// 4. Attaches user to req.user for controllers to access
+ +

RolesGuard — checks user permissions

+
@Injectable()
+export class RolesGuard implements CanActivate {
+  constructor(private readonly reflector: Reflector) {}
+  // Reflector reads metadata attached by decorators
+
+  canActivate(context: ExecutionContext): boolean {
+    // 1. Read the @Roles(...) metadata from the route handler
+    const required = this.reflector.getAllAndOverride<UserRole[]>(
+      ROLES_KEY,
+      [context.getHandler(), context.getClass()]
+    );
+
+    if (!required?.length) return true;  // no @Roles = open to all authenticated users
+
+    // 2. Get the user from req.user (attached by JwtAuthGuard)
+    const { user } = context.switchToHttp().getRequest();
+
+    // 3. Compare priorities: ADMIN=4, EDITOR=3, REVIEWER=2, READONLY=1
+    const userPriority = ROLE_PRIORITY[user.role] ?? 0;
+    const minRequired = Math.min(...required.map((r) => ROLE_PRIORITY[r]));
+
+    if (userPriority < minRequired) throw new ForbiddenException('Insufficient role');
+    return true;
+  }
+}
+ +

Custom @Roles decorator

+
// src/modules/auth/decorators/roles.decorator.ts
+export const ROLES_KEY = 'roles';
+
+// SetMetadata attaches data to a route so guards can read it with Reflector
+export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
+
+// Usage:
+@Roles(UserRole.EDITOR)  // stores ['EDITOR'] in metadata under the key 'roles'
+update() { ... }
+// RolesGuard then reads this metadata and checks the user's role against it
+ + +

Pipes & Validation

+ +

A pipe runs before the controller method and transforms or validates the incoming data. The global ValidationPipe set up in main.ts automatically validates every @Body(), @Query(), and @Param() argument that uses a DTO class.

+ +

Data Transfer Objects (DTOs)

+

A DTO (Data Transfer Object) is a plain class decorated with class-validator decorators. The ValidationPipe reads these decorators at runtime and rejects requests that don't match.

+ +
// src/modules/videos/dto/query-videos.dto.ts
+export class QueryVideosDto {
+  @IsOptional()      // field may be absent — but if present, must pass other validators
+  @IsString()        // must be a string
+  search?: string;
+
+  @IsOptional()
+  @IsEnum(LintStatus) // must be one of the LintStatus enum values
+  lintStatus?: LintStatus;
+
+  @IsOptional()
+  @Type(() => Number)  // transforms the string "10" from the URL into the number 10
+  @IsInt()             // must be an integer
+  @Min(1)              // must be >= 1
+  page?: number = 1;   // defaults to 1 if not provided
+}
+ +

If someone sends GET /api/v1/videos?page=abc, the ValidationPipe returns 400 Bad Request with a clear error message — the controller method never runs.

+ +
+ whitelist: true + The global ValidationPipe is configured with whitelist: true. This strips any properties from the request body that are NOT declared in the DTO class. This prevents attackers from injecting unexpected fields. +
+ + +

Built-in Decorators Reference

+ + + + + + + + + + + + + + + + + + +
DecoratorWhere usedWhat it does
@Module()ClassMarks a class as a NestJS module
@Injectable()ClassMarks a class as a provider (can be injected)
@Controller('path')ClassMarks a class as an HTTP controller with a route prefix
@Global()Module classModule's exports available everywhere without importing
@Get() @Post() @Patch() @Put() @Delete()MethodMaps method to an HTTP route
@Param('name')ParameterExtracts a URL path parameter
@Query()ParameterExtracts query string parameters as an object
@Body()ParameterExtracts and validates the request body
@Req()ParameterInjects the full Express Request object
@Res()ParameterInjects the full Express Response object
@UseGuards(...)Class/MethodAttaches guards to a controller or method
@InjectQueue('name')ParameterInjects a BullMQ Queue by name
@Processor('name')ClassMarks a class as a BullMQ queue processor
+ + +

Lifecycle Hooks

+ +

NestJS calls special methods at specific points in the application's life. You implement them by adding the interface to your class.

+ +
// OnModuleInit — runs once after the module's dependencies are resolved
+export class PrismaService extends PrismaClient
+    implements OnModuleInit, OnModuleDestroy {
+
+  async onModuleInit() {
+    await this.$connect();    // connect to database when app starts
+  }
+
+  async onModuleDestroy() {
+    await this.$disconnect(); // close connection when app shuts down
+  }
+}
+
+// OnModuleInit used to seed default data
+export class SavedViewsService implements OnModuleInit {
+  async onModuleInit() {
+    // Create the 4 default saved views if they don't already exist
+    for (const view of DEFAULT_VIEWS) {
+      const existing = await this.prisma.savedView.findFirst({ where: { name: view.name } });
+      if (!existing) await this.prisma.savedView.create({ data: view });
+    }
+  }
+}
+ + + +
+ + + + diff --git a/backend/docs/guide-part2.html b/backend/docs/guide-part2.html new file mode 100644 index 0000000..bf95fce --- /dev/null +++ b/backend/docs/guide-part2.html @@ -0,0 +1,666 @@ + + + + + +StudioFlow Backend — Developer Guide (Part 2 of 3) + + + + + + +
+ +

StudioFlow Backend

+

Developer Guide — Part 2 of 3

+

Database with Prisma · Authentication with Google OAuth & JWT

+ + +

What Is Prisma

+ +

Prisma is an ORM (Object-Relational Mapper) — a tool that lets you talk to a SQL database using TypeScript instead of raw SQL. You define your database schema in a schema.prisma file, and Prisma generates a fully type-safe client that knows the exact shape of every table.

+ +

The workflow is:

+
+
Edit schema.prisma
+
npx prisma migrate dev
+
SQL migration created & applied
+
npx prisma generate
+
Type-safe client in node_modules/@prisma/client
+
+ +

The generated client knows your exact schema. If you write prisma.video.findUnique({ where: { id } }), TypeScript knows the return type includes title, tags, lintStatus, and every other field you defined.

+ + +

Schema Overview

+ +

The database has 17 tables (called models in Prisma). Here is the full picture:

+ + + + + + + + + + + + + + + + + + + + + + + +
ModelPurpose
VideoOne row per YouTube video. Stores title, tags, privacy status, sync state, lint status.
VideoConfigThe rendering configuration for a video — which blocks to use, in what order, with what variable values.
DescriptionBlockA reusable chunk of description text. Has a type (STATIC, VARIABLE, CONDITIONAL, etc.).
BlockVersionSnapshot of a block at the moment it was edited — full history.
TemplateA named set of default blocks and rules applied to a category of videos.
TemplateVersionSnapshot of a template at the moment it was edited.
CollaboratorA person who appears in videos — stores name, YouTube handle, Twitch link.
VideoCollaboratorJunction table linking a Video to a Collaborator (many-to-many).
SavedViewA named filter preset — stores a Prisma where clause as JSON.
LintResultOne row per quality issue found by a lint rule.
BulkJobTracks a batch operation (e.g. "change privacy on 200 videos").
BulkJobItemOne row per video in a bulk job — stores before/after snapshots.
CampaignA sponsor campaign with a date range. Blocks can belong to a campaign.
ImportJobTracks a CSV or JSON import — validation report and commit status.
ExportJobTracks an export operation.
QuotaLogEvery YouTube API call is logged here — used to enforce the 9,000 unit/day limit.
AuditLogEvery write operation is logged here — who did what, before and after state.
UserA logged-in user — stores Google OAuth data and encrypted YouTube tokens.
+ + +

Key Models in Detail

+ +

Video — the central entity

+
+model Video {
+  id String @id @default(cuid()) // auto-generated unique ID
+  youtubeVideoId String @unique // e.g. "dQw4w9WgXcQ"
+  channelId String
+  title String
+  renderedDescription String? // null until first render
+  tags String[] // PostgreSQL array
+  privacyStatus PrivacyStatus @default(PRIVATE)
+  lintStatus LintStatus @default(OK)
+  lastSyncedHash String? // SHA-256 of last synced state
+  remoteConflict Boolean @default(false)
+  template Template? @relation(...) // optional foreign key
+  config VideoConfig? // one-to-one
+  collaborators VideoCollaborator[] // many-to-many via junction table
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt // auto-updated on every write
+} +
+ +

VideoConfig — the rendering recipe

+
+model VideoConfig {
+  videoId String @unique // one config per video
+  blockOrder Json // String[] — ordered block IDs e.g. ["abc", "def"]
+  blockOverrides Json // { blockId: { content?: string, active?: bool } }
+  variableValues Json // { sponsorName: "Squarespace", link: "..." }
+  collaboratorIds Json // String[] — which collaborators appear in this video
+  version Int @default(1) // incremented on every update
+  renderHash String? // SHA-256 of last render output
+} +
+ +

The Json type stores arbitrary JSON in a PostgreSQL JSONB column. Prisma returns it as unknown, so the code casts it with as string[] or as Record<string, any> where needed.

+ +

DescriptionBlock — reusable text chunks

+
+model DescriptionBlock {
+  id String @id @default(cuid())
+  name String // human-readable name
+  type BlockType // STATIC | VARIABLE | CONDITIONAL | ...
+  content String // the text, may contain {variables}
+  campaignId String? // optional link to a Campaign
+  version Int @default(1) // incremented on every edit
+  versions BlockVersion[] // full edit history
+} +
+ +

Block types and what they do

+ + + + + + + + + + + +
TypeBehaviour in the render engine
STATICPlain text. Always included if active. Variables still resolved.
VARIABLEText with {variable} placeholders filled from variableValues.
CONDITIONALStarts with [if:variableName]. Skipped entirely if that variable is falsy.
REPEATABLERendered once per item in an array variable.
GLOBALShared across all videos — e.g. channel-wide footer.
CAMPAIGNLinked to a Campaign. The outdated-sponsor lint rule checks its end date.
COLLABORATORExpanded once per assigned collaborator with their name/handle injected.
+ + +

Relations in Prisma

+ +

Prisma relations mirror SQL foreign keys but add TypeScript types so you can navigate them in queries.

+ +

One-to-one: Video ↔ VideoConfig

+
// In schema.prisma — each Video has at most one VideoConfig
+model Video {
+  config  VideoConfig?   // the ? means it might not exist yet
+}
+model VideoConfig {
+  videoId String  @unique          // foreign key
+  video   Video   @relation(fields: [videoId], references: [id])
+}
+
+// In TypeScript — include loads the related record in one query
+const video = await prisma.video.findUnique({
+  where: { id },
+  include: { config: true },   // video.config is now a VideoConfig object (or null)
+});
+ +

One-to-many: DescriptionBlock → BlockVersion

+
// One block has many versions (one per edit)
+model DescriptionBlock {
+  versions  BlockVersion[]   // array relation
+}
+model BlockVersion {
+  blockId  String
+  block    DescriptionBlock  @relation(fields: [blockId], references: [id])
+}
+
+// Querying: get the block with all its versions
+prisma.descriptionBlock.findUnique({
+  where: { id },
+  include: { versions: { orderBy: { version: 'desc' } } }
+});
+ +

Many-to-many: Video ↔ Collaborator (via junction table)

+
// A video has many collaborators; a collaborator appears in many videos
+// The junction table VideoCollaborator stores the link + extra data (role, sortOrder)
+model VideoCollaborator {
+  videoId        String
+  collaboratorId String
+  role           String?     // e.g. "guest", "editor"
+  sortOrder      Int
+  @@id([videoId, collaboratorId])   // composite primary key
+}
+
+// Loading collaborators for a video
+prisma.video.findUnique({
+  where: { id },
+  include: {
+    collaborators: {
+      include: { collaborator: true }   // two levels of include
+    }
+  }
+});
+ + +

Prisma Client Query API

+ +

The generated Prisma client exposes a consistent API for every model. Here are the methods used throughout this project:

+ + + + + + + + + + + + + + + + + +
MethodReturnsUse case
findUnique({ where })Record or nullFind by ID or unique field
findUniqueOrThrow({ where })Record (throws if not found)When absence is an error
findFirst({ where })Record or nullFind first matching record
findMany({ where, orderBy, skip, take, include })ArrayPaginated list queries
create({ data })New recordInsert a new row
update({ where, data })Updated recordUpdate a specific row
upsert({ where, create, update })Created or updated recordInsert or update atomically
delete({ where })Deleted recordDelete a specific row
count({ where })NumberCount matching rows
aggregate({ _sum, where })Aggregation resultSum, avg, min, max
createMany({ data }){ count: number }Bulk insert
deleteMany({ where }){ count: number }Bulk delete
+ +

Filtering with where

+
// Simple equality
+where: { id: 'abc', lintStatus: LintStatus.ERROR }
+
+// String operators
+where: { title: { contains: 'tutorial', mode: 'insensitive' } }
+
+// Array operators
+where: { tags: { has: 'sponsor' } }            // array contains value
+where: { blockOrder: { array_contains: id } }   // JSON array contains value
+
+// Date range
+where: { publishedAt: { gte: new Date('2026-01-01'), lte: new Date('2026-12-31') } }
+
+// OR — fulltext search across multiple fields
+where: {
+  OR: [
+    { title: { contains: search, mode: 'insensitive' } },
+    { tags: { has: search } },
+  ],
+}
+
+// Nested relation filter — videos that have this collaborator
+where: { collaborators: { some: { collaboratorId: id } } }
+ +

Pagination pattern

+
// Skip/take is SQL OFFSET/LIMIT
+prisma.video.findMany({
+  where,
+  orderBy: { [sort]: order },   // dynamic sort column
+  skip: (page - 1) * limit,    // skip the first N-1 pages
+  take: limit,                  // return at most `limit` rows
+});
+
+// Always fetch count and items in parallel for efficiency
+const [total, items] = await Promise.all([
+  prisma.video.count({ where }),
+  prisma.video.findMany({ where, skip, take }),
+]);
+return { total, page, limit, items };
+ + +

PrismaService

+ +

Rather than using the generated PrismaClient directly, the project wraps it in a NestJS service. This gives NestJS control over the lifecycle — connecting when the app starts, disconnecting when it shuts down.

+ +
// src/shared/prisma/prisma.service.ts
+@Injectable()
+export class PrismaService
+    extends PrismaClient             // IS a PrismaClient — inherits all query methods
+    implements OnModuleInit, OnModuleDestroy {
+
+  async onModuleInit() {
+    await this.$connect();
+    // From this moment, this.prisma.video.findMany() works
+  }
+
+  async onModuleDestroy() {
+    await this.$disconnect();
+    // Closes all database connections cleanly
+  }
+}
+
+// Because PrismaModule is @Global(), any service can inject PrismaService
+// just by adding it to its constructor — no need to import PrismaModule everywhere:
+@Injectable()
+export class AnyService {
+  constructor(private readonly prisma: PrismaService) {}
+  // this.prisma.video.findMany() — works immediately
+}
+ + +

Transactions

+ +

A database transaction groups multiple writes into an atomic unit — either all succeed or none are applied. This prevents partial states (e.g. a lint result written but the video's lintStatus not updated).

+ +
// src/modules/linting/linting.service.ts — atomic lint result replacement
+await this.prisma.$transaction([
+  // Step 1: delete all old unresolved results
+  this.prisma.lintResult.deleteMany({
+    where: { videoId, resolvedAt: null }
+  }),
+  // Step 2: insert new results (if any)
+  ...(issues.length > 0
+    ? [this.prisma.lintResult.createMany({ data: issues })]
+    : []),
+  // Step 3: update video's overall lint status
+  this.prisma.video.update({
+    where: { id: videoId },
+    data: { lintStatus: this.computeStatus(severities) },
+  }),
+]);
+// All three run in a single SQL transaction — atomic and consistent
+ +
+ $transaction array vs callback + Passing an array of Prisma promises (as above) runs them in a single transaction. There's also an interactive transaction using a callback: $transaction(async (tx) => { const x = await tx.foo.create(...); ... }) which lets you use results from one query in the next — at the cost of a longer-held lock. +
+ + +

Authentication Overview

+ +

The project uses a two-layer authentication strategy:

+ + + + + + + +
LayerTechnologyLifetimeHow used
Access TokenJWT (HS256)15 minutesSent as Bearer token in Authorization header for every API request
Refresh TokenJWT (HS256, different secret)7 daysStored in httpOnly cookie; used to issue new access tokens silently
+ +

For the initial login, the project uses Google OAuth 2.0 — users click "Login with Google", are redirected to Google's consent page, and come back with a code that the backend exchanges for tokens.

+ + +

Google OAuth Flow — Step by Step

+ +
+
1. User clicks Login
Frontend redirects to GET /api/v1/auth/google
+
2. NestJS redirects
Passport's GoogleStrategy builds the Google OAuth URL and redirects the browser
+
3. User consents
User sees Google's permission screen and clicks Allow
+
4. Google callback
Google sends the user to GET /api/v1/auth/google/callback?code=...
+
5. Upsert user
GoogleStrategy.validate() calls AuthService.upsertGoogleUser() — creates or updates the DB row
+
6. Issue tokens
Controller issues JWT access token (15m) + sets refresh token in httpOnly cookie (7d)
+
7. Redirect to frontend
Browser sent to http://localhost:3000/auth/callback?token=eyJ...
+
8. Frontend stores token
Frontend reads ?token= from URL and stores in memory/localStorage for API requests
+
+ +
// src/modules/auth/auth.controller.ts — the callback handler
+@Get('google/callback')
+@UseGuards(AuthGuard('google'))    // Passport exchanges the code for tokens
+async googleCallback(@Req() req: Request, @Res() res: Response) {
+  const user = req.user as any;   // set by GoogleStrategy.validate()
+
+  const accessToken = this.authService.issueJwt(user);
+  const refreshToken = this.authService.issueRefreshToken(user);
+
+  res.cookie('refresh_token', refreshToken, {
+    httpOnly: true,     // JavaScript in the browser CANNOT read this cookie
+    secure: process.env.NODE_ENV === 'production',  // HTTPS only in prod
+    sameSite: 'lax',    // CSRF protection
+    maxAge: 7 * 24 * 60 * 60 * 1000,  // 7 days in milliseconds
+  });
+
+  res.redirect(`${frontendUrl}/auth/callback?token=${accessToken}`);
+}
+ + +

JWT Tokens

+ +

A JWT (JSON Web Token) is a self-contained credential. It has three parts separated by dots: header.payload.signature. The payload contains claims (data); the signature proves the token was issued by the server.

+ +
// The JWT payload for this project looks like:
+{
+  "sub": "clx8abc123",      // subject = user.id
+  "email": "alice@example.com",
+  "role": "EDITOR",
+  "iat": 1714300000,        // issued at (Unix timestamp)
+  "exp": 1714300900         // expires at (15 minutes later)
+}
+
+// Issuing the JWT — signed with JWT_SECRET from .env
+issueJwt(user: User): string {
+  return this.jwt.sign(
+    { sub: user.id, email: user.email, role: user.role },
+    { expiresIn: '15m' }
+  );
+}
+
+// Verifying — JwtStrategy reads the token from Authorization: Bearer <token>
+async validate(payload: JwtPayload) {
+  // The token's signature is already verified by passport-jwt
+  // We additionally load the user from DB to ensure they still exist
+  const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
+  if (!user) throw new UnauthorizedException();
+  return user;   // attached to req.user
+}
+ +
+ Why 15 minutes? + Short-lived access tokens limit the damage if one is stolen — it becomes worthless quickly. The refresh token (7 days) lives in an httpOnly cookie which JavaScript cannot read, making it much harder to steal via XSS. +
+ + +

YouTube Token Encryption

+ +

Google issues OAuth tokens that grant access to the user's YouTube account. These are extremely sensitive — storing them in plaintext in the database would be a serious security vulnerability.

+ +

The project encrypts them with AES-256-CBC before storage and decrypts on demand.

+ +
// src/modules/auth/auth.service.ts
+
+// At startup — derive a 256-bit (32-byte) encryption key from the env variable
+// scryptSync is a key derivation function — it's slow on purpose to resist brute force
+private readonly encKey: Buffer;
+
+constructor(...) {
+  const raw = config.get<string>('TOKEN_ENCRYPTION_KEY');
+  this.encKey = scryptSync(raw, 'studioflow-salt', 32);
+  // salt is a fixed string here — in production use a random per-key salt
+}
+
+private encrypt(text: string): string {
+  const iv = randomBytes(16);              // 16-byte random IV (Initialization Vector)
+  const cipher = createCipheriv('aes-256-cbc', this.encKey, iv);
+  const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
+  return iv.toString('hex') + ':' + encrypted.toString('hex');
+  // Stored as: "a1b2c3d4...:e5f6a7b8..." (iv:ciphertext, both hex-encoded)
+}
+
+private decrypt(text: string): string {
+  const [ivHex, encHex] = text.split(':');
+  const iv = Buffer.from(ivHex, 'hex');
+  const encrypted = Buffer.from(encHex, 'hex');
+  const decipher = createDecipheriv('aes-256-cbc', this.encKey, iv);
+  return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
+}
+ +
+ Why a random IV each time? + AES-CBC without a random IV would produce the same ciphertext for the same plaintext. That leaks information — an attacker who sees two identical ciphertexts knows the underlying tokens match. A fresh random IV for every encryption ensures ciphertexts are always different, even for identical inputs. +
+ + +

Passport Strategies

+ +

Passport.js is an authentication middleware library with a plugin model called strategies. Each strategy knows how to authenticate a specific way — Google OAuth, JWT, local username/password, etc.

+ +

GoogleStrategy — handles OAuth dance

+
// src/modules/auth/strategies/google.strategy.ts
+@Injectable()
+export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
+  constructor(config: ConfigService, private readonly authService: AuthService) {
+    super({
+      clientID: config.getOrThrow('GOOGLE_CLIENT_ID'),
+      clientSecret: config.getOrThrow('GOOGLE_CLIENT_SECRET'),
+      callbackURL: config.get('GOOGLE_CALLBACK_URL'),
+      scope: ['email', 'profile', 'https://www.googleapis.com/auth/youtube'],
+      // The youtube scope lets us call YouTube Data API on the user's behalf
+    });
+  }
+
+  async validate(accessToken: string, refreshToken: string, profile: any) {
+    // Called after Google confirms the user authenticated successfully
+    // accessToken — short-lived token for YouTube API calls
+    // refreshToken — long-lived token to get new access tokens (only sent once!)
+    // profile — { id, displayName, emails, photos, ... }
+    const user = await this.authService.upsertGoogleUser(profile, accessToken, refreshToken);
+    return user;   // attached to req.user by Passport
+  }
+}
+ +

JwtStrategy — validates every API request

+
// src/modules/auth/strategies/jwt.strategy.ts
+@Injectable()
+export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
+  constructor(config: ConfigService, private readonly prisma: PrismaService) {
+    super({
+      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
+      // Reads: Authorization: Bearer eyJhbGci...
+      ignoreExpiration: false,    // reject expired tokens
+      secretOrKey: config.getOrThrow('JWT_SECRET'),
+    });
+  }
+
+  async validate(payload: JwtPayload) {
+    // At this point, passport-jwt has already verified the signature and expiry
+    // We do a final DB lookup to ensure the user still exists
+    const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
+    if (!user) throw new UnauthorizedException();
+    return user;   // becomes req.user
+  }
+}
+ + +

AuthService — upsertGoogleUser

+ +

The upsertGoogleUser method is called every time a user logs in via Google. "Upsert" means: create if new, update if exists. This handles both first-time signups and returning users transparently.

+ +
async upsertGoogleUser(profile: any, accessToken: string, refreshToken: string) {
+  const email   = profile.emails?.[0]?.value;   // primary email
+  const googleId = profile.id;                    // stable Google account ID
+  const name     = profile.displayName;
+
+  return this.prisma.user.upsert({
+    where: { googleId },           // find by googleId (unique)
+    create: {                       // first login — create the row
+      email, name, googleId,
+      youtubeAccessToken: this.encrypt(accessToken),
+      youtubeRefreshToken: refreshToken ? this.encrypt(refreshToken) : undefined,
+      youtubeTokenExpiry: new Date(Date.now() + 3600 * 1000),
+    },
+    update: {                       // returning user — refresh their tokens
+      email, name,
+      youtubeAccessToken: this.encrypt(accessToken),
+      youtubeRefreshToken: refreshToken ? this.encrypt(refreshToken) : undefined,
+      youtubeTokenExpiry: new Date(Date.now() + 3600 * 1000),
+    },
+  });
+}
+ + +

Refresh Token Flow

+ +

When the frontend's 15-minute access token expires, it calls POST /api/v1/auth/refresh with the httpOnly cookie. The server verifies the refresh token and issues a new access token — the user doesn't need to log in again.

+ +
// POST /auth/refresh — no body needed, cookie is sent automatically
+@Post('refresh')
+async refresh(@Req() req: Request) {
+  const token = req.cookies?.['refresh_token'];
+  return this.authService.refreshAccessToken(token);
+}
+
+// AuthService.refreshAccessToken
+async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string }> {
+  // Verify the refresh token using the REFRESH secret (different from JWT_SECRET)
+  const payload = this.jwt.verify<{ sub: string }>(refreshToken, {
+    secret: this.config.get('JWT_REFRESH_SECRET'),
+  });
+  // Load the user and issue a fresh access token
+  const user = await this.prisma.user.findUniqueOrThrow({ where: { id: payload.sub } });
+  return { accessToken: this.issueJwt(user) };
+}
+ +
+ Two secrets, two purposes + JWT_SECRET signs access tokens (15 min). JWT_REFRESH_SECRET signs refresh tokens (7 days). Using separate secrets means a compromised access token cannot be upgraded to a long-lived refresh token — the two are cryptographically independent. +
+ +

Complete Auth API endpoints

+ + + + + + + + +
MethodPathAuth requiredWhat it does
GET/auth/googleNoRedirects browser to Google's consent screen
GET/auth/google/callbackNo (Google callback)Exchanges code for tokens, issues JWT, sets cookie, redirects
GET/auth/meJWTReturns the current user object from the database
POST/auth/refreshCookieIssues a new access token using the refresh cookie
+ + + +
+ + + + diff --git a/backend/docs/guide-part3.html b/backend/docs/guide-part3.html new file mode 100644 index 0000000..852a00a --- /dev/null +++ b/backend/docs/guide-part3.html @@ -0,0 +1,1118 @@ + + + + + +StudioFlow Backend — Developer Guide (Part 3 of 3) + + + + + + +
+ +

StudioFlow Backend

+

Developer Guide — Part 3 of 3

+

Render Engine · Linting · BullMQ Queues · API Reference · Setup

+ + +

How Rendering Works

+ +

The render engine is the heart of StudioFlow. It takes a video's configuration and assembles the final description text that gets pushed to YouTube. It is a pure TypeScript function — no database calls, no I/O, just data in and text out. This makes it fast, testable, and deterministic.

+ +

A rendered description is built from blocks — reusable text chunks that are assembled in a specific order. Each block can have its content overridden per video, variables filled in, and conditional logic applied.

+ +

Input and output types

+
// src/shared/render-engine/render-engine.service.ts
+
+// INPUT: everything the engine needs
+export interface RenderInput {
+  videoTitle: string;
+  videoTags: string[];
+  videoCategoryId?: string | null;
+  config: {
+    blockOrder: string[];                // ordered list of block IDs
+    blockOverrides: Record<string, {     // per-video overrides
+      content?: string;
+      active?: boolean;
+    }>;
+    variableValues: Record<string, string>;  // { sponsorName: "Squarespace" }
+    collaboratorIds: string[];
+  };
+  blocks: RenderBlock[];        // fetched from DB before calling render()
+  collaborators: RenderCollaborator[];
+}
+
+// OUTPUT: the rendered text + a hash of the result
+export interface RenderResult {
+  rendered: string;   // the final description ready to send to YouTube
+  hash: string;       // SHA-256 fingerprint of { title, description, tags, categoryId }
+}
+ + +

Render Pipeline — Step by Step

+ +

The render(input) method processes each block ID in blockOrder from top to bottom:

+ +
render(input: RenderInput): RenderResult {
+  const blockMap = new Map(blocks.map((b) => [b.id, b]));
+  const lines: string[] = [];
+
+  for (const blockId of config.blockOrder) {
+    const block = blockMap.get(blockId);
+    if (!block) continue;    // block deleted from DB but still in config
+
+    // ① Apply override: per-video active flag and content
+    const override = config.blockOverrides[blockId] ?? {};
+    const isActive = override.active !== undefined ? override.active : block.active;
+    if (!isActive) continue;   // skip disabled blocks
+
+    let content = override.content !== undefined ? override.content : block.content;
+
+    // ② Resolve {variable} placeholders from variableValues
+    content = this.resolveVariables(content, config.variableValues);
+
+    // ③ Inject collaborator data ({collab_name}, {@youtube_handle})
+    content = this.resolveCollaborators(content, collaborators, config.collaboratorIds);
+
+    // ④ CONDITIONAL blocks — skip if the condition variable is falsy
+    if (block.type === BlockType.CONDITIONAL) {
+      const condVar = this.extractConditionVar(content);  // parses [if:varName]
+      if (condVar && !config.variableValues[condVar]) continue;
+    }
+
+    // ⑤ COLLABORATOR blocks — expand once per collaborator assigned to this video
+    if (block.type === BlockType.COLLABORATOR) {
+      lines.push(this.expandCollaboratorBlock(content, collaborators, config.collaboratorIds));
+      continue;
+    }
+
+    lines.push(content);
+  }
+
+  const rendered = lines.join('\n\n');   // blocks separated by blank lines
+
+  // ⑥ Compute hash of the entire metadata set
+  const hash = hashMetadata({ title: videoTitle, description: rendered, tags, categoryId });
+
+  return { rendered, hash };
+}
+ +

Variable resolution

+
// Replaces {variableName} with the corresponding value
+// Unresolved placeholders remain in the text (caught by the DESC_EMPTY_PLACEHOLDER lint rule)
+
+// Regex: { followed by a letter/underscore, then letters/digits/underscores, then }
+// The g flag replaces ALL occurrences in the string
+content.replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (match, key) => {
+  return values[key] !== undefined ? values[key] : match;
+});
+
+// Example:
+// content: "Sponsored by {sponsorName} — use code {promoCode} for 20% off!"
+// values:  { sponsorName: "Squarespace", promoCode: "STUDIO20" }
+// result:  "Sponsored by Squarespace — use code STUDIO20 for 20% off!"
+ +

Collaborator expansion

+
// COLLABORATOR blocks are rendered once per collaborator assigned to the video
+private expandCollaboratorBlock(template, collaborators, collaboratorIds) {
+  const active = collaborators.filter((c) => collaboratorIds.includes(c.id));
+
+  // Map each collaborator to a copy of the block with their data substituted
+  return active.map((c) =>
+    template
+      .replace(/\{collab_name\}/g, c.name)
+      .replace(/\{@youtube_handle\}/g, c.youtubeHandle)
+      .replace(/\{twitch_link\}/g, c.twitchLink ?? '')
+  ).join('\n\n');
+}
+
+// Block template: "Big thanks to {collab_name} — subscribe: {#youtube_handle}"
+// With 2 collaborators: produces two paragraphs, one per collaborator
+ + +

Change Detection with Hashing

+ +

Before pushing a video to YouTube (which costs 50 API quota units), the sync processor checks whether anything actually changed. If the rendered text, title, tags, and category are identical to the last sync, the update is skipped entirely.

+ +
// src/shared/render-engine/hash.ts
+
+// SHA-256 hash of the stringified metadata object
+// JSON.stringify produces the same string for the same object
+// (as long as key order is consistent — it is here because it's hardcoded)
+export function hashMetadata(data: {
+  title: string;
+  description: string;
+  tags: string[];
+  categoryId?: string | null;
+}): string {
+  return createHash('sha256')
+    .update(JSON.stringify(data))
+    .digest('hex');  // 64-character hex string
+}
+
+export function hasChanged(lastSyncedHash: string | null | undefined, newHash: string): boolean {
+  return lastSyncedHash !== newHash;
+}
+
+// In the YouTube sync processor:
+if (!hasChanged(video.lastSyncedHash, newHash)) {
+  return { skipped: true, reason: 'no-change' };   // 50 quota units saved!
+}
+ +
+ Why SHA-256? + SHA-256 produces a fixed 64-character string regardless of input length. Any change — even a single character in the description — produces a completely different hash. Comparing two 64-character strings is O(1), no matter how long the description is. +
+ + +

Linting Overview

+ +

The linting system automatically checks the quality of every video's description, title, and configuration. It runs after every render and reports issues that editors need to fix before publishing.

+ +

The design uses the strategy pattern: a common interface (LintRule) that all rules implement. The LintingService holds an array of all rule instances and iterates over them — adding a new rule requires only creating a new file and adding it to the array.

+ +
// src/modules/linting/rules/base.rule.ts
+export interface LintIssue {
+  message: string;
+  targetField?: string;       // 'title', 'description', 'sync'
+  fixSuggestion?: string;    // human-readable hint
+}
+
+export interface LintRule {
+  code: string;               // e.g. 'TITLE_WEAK'
+  severity: LintSeverity;    // INFO | WARNING | ERROR
+  check(video: any): LintIssue | null;
+}
+ + +

All 10 Lint Rules

+ +
+
+
TITLE_WEAK
+
WARNING
+

Title shorter than 20 characters or contains generic words like "video", "test", "untitled".

+
+
+
TITLE_TOO_LONG
+
WARNING
+

Title exceeds 100 characters. YouTube truncates long titles in search results.

+
+
+
DESC_MISSING_CTA
+
WARNING
+

The rendered description contains none of: subscribe, like, follow, comment, cta.

+
+
+
DESC_MISSING_CHAPTERS
+
WARNING
+

Fewer than 2 timestamp patterns (e.g. "0:00", "1:23") found in the description.

+
+
+
DESC_EMPTY_PLACEHOLDER
+
ERROR
+

The rendered description still contains unresolved {variable} placeholders.

+
+
+
DESC_DUPLICATE_HASHTAG
+
WARNING
+

The same #hashtag appears more than once in the description.

+
+
+
DESC_REQUIRED_LINK_MISSING
+
ERROR
+

The video's template defines required links (e.g. merch URL) that are absent from the description.

+
+
+
DESC_OUTDATED_SPONSOR_COPY
+
ERROR
+

A CAMPAIGN block is present and the linked campaign's endAt date has passed.

+
+
+
COLLAB_REFERENCE_INVALID
+
ERROR
+

A collaborator ID listed in the video config doesn't match any VideoCollaborator row in the DB.

+
+
+
REMOTE_CONFLICT
+
ERROR
+

The video's remoteConflict flag is true — YouTube's metadata differs from our last known sync.

+
+
+ +

Example rule implementation

+
// src/modules/linting/rules/desc-empty-placeholder.rule.ts
+
+// This regex matches any {variable} that remains in the description after rendering
+const PLACEHOLDER_REGEX = /\{[a-z_][a-z0-9_]*\}/g;
+
+export class DescEmptyPlaceholderRule implements LintRule {
+  code = 'DESC_EMPTY_PLACEHOLDER';
+  severity = LintSeverity.ERROR;    // ERROR = blocks publishing
+
+  check(video: any): LintIssue | null {
+    const desc: string = video.renderedDescription ?? '';
+
+    // String.match() with a /g regex returns ALL matches as an array
+    const unresolved = desc.match(PLACEHOLDER_REGEX);
+
+    if (unresolved?.length) {
+      return {
+        message: `Unresolved placeholders: ${unresolved.join(', ')}`,
+        targetField: 'description',
+        fixSuggestion: 'Fill in all variable values before publishing.',
+      };
+    }
+    return null;   // null = no issue
+  }
+}
+ + +

LintingService

+ +

The service runs all rules against a video and replaces the previous lint results atomically. The overall lintStatus on the Video record is then updated to reflect the worst severity found.

+ +
@Injectable()
+export class LintingService {
+  // All rules as singleton instances — no DI needed, they have no dependencies
+  private readonly rules: LintRule[] = [
+    new TitleWeakRule(), new TitleTooLongRule(),
+    new DescMissingCtaRule(), new DescMissingChaptersRule(),
+    new DescEmptyPlaceholderRule(), new DescDuplicateHashtagRule(),
+    new DescRequiredLinkMissingRule(), new DescOutdatedSponsorRule(),
+    new CollabReferenceInvalidRule(), new RemoteConflictRule(),
+  ];
+
+  async lintVideo(videoId: string) {
+    // Load video with everything the rules might need
+    const video = await this.prisma.video.findUniqueOrThrow({
+      where: { id: videoId },
+      include: { config: true, template: true, collaborators: { include: { collaborator: true } } },
+    });
+
+    // Run ALL rules and collect non-null results
+    const issues = this.rules
+      .map((rule) => rule.check(video))  // [null, issue, null, issue, ...]
+      .filter(Boolean)                     // remove nulls
+      .map((issue, i) => ({              // shape for createMany
+        videoId,
+        ruleCode: this.rules[i].code,
+        severity: this.rules[i].severity,
+        ...issue,
+      }));
+
+    // Atomically: delete old results + insert new ones + update video status
+    await this.prisma.$transaction([
+      this.prisma.lintResult.deleteMany({ where: { videoId, resolvedAt: null } }),
+      ...(issues.length ? [this.prisma.lintResult.createMany({ data: issues })] : []),
+      this.prisma.video.update({
+        where: { id: videoId },
+        data: { lintStatus: this.computeStatus(issues.map((i) => i.severity)) },
+      }),
+    ]);
+
+    return issues;
+  }
+
+  // Escalation: ERROR > WARNING > OK
+  private computeStatus(severities: LintSeverity[]): LintStatus {
+    if (severities.includes(LintSeverity.ERROR)) return LintStatus.ERROR;
+    if (severities.includes(LintSeverity.WARNING)) return LintStatus.WARNING;
+    return LintStatus.OK;
+  }
+}
+ + +

BullMQ Overview

+ +

BullMQ is a queue library that uses Redis as its backing store. A queue is like a to-do list — producers add jobs to it, consumers (processors) pick them up and execute them. This separates the HTTP request cycle (fast) from the actual work (slow).

+ +

Key concepts:

+ + + + + + + + + + + +
ConceptDescription
QueueA named list of jobs stored in Redis. Producers add to it.
JobA unit of work with a JSON payload. Has a status: waiting → active → completed/failed.
ProcessorA class decorated with @Processor(queueName) that implements process(job).
WorkerThe running instance that pulls jobs from the queue and calls the processor.
ConcurrencyHow many jobs a processor handles simultaneously. Default is unlimited.
Delayed jobsjob.moveToDelayed(timestamp) puts a job on hold until a future time.
Rate limiterRestricts how many jobs start in a time window (e.g. max 5 per 10 seconds).
+ + +

The 5 Queues

+ +
+

youtube-sync

+

Producer: VideosService.enqueueSyncJob() — called when a user clicks "Sync to YouTube"
+ Processor: YouTubeSyncProcessor
+ Payload: { videoId: string; userId: string }
+ Special: concurrency=1 (one sync at a time), rate-limited to 5 per 10 seconds. userId identifies which user's stored OAuth token to use for the YouTube API call.

+
+ +
+

bulk-metadata

+

Producer: BulkJobsService.enqueueItems() — one job per video in the bulk operation
+ Processor: BulkMetadataProcessor
+ Payload: { bulkJobId: string, itemId: string }

+
+ +
+

render

+

Producer: VideoConfigsService when autoRender: true
+ Processor: RenderProcessor
+ Payload: { videoId: string }
+ Special: automatically enqueues a lint job when finished

+
+ +
+

lint

+

Producer: RenderProcessor (chained) or LintingController
+ Processor: LintProcessor
+ Payload: { videoId: string }

+
+ +
+

import

+

Producer: ImportsService.commitCsv()
+ Processor: ImportProcessor
+ Payload: { importJobId: string }

+
+ + +

YouTube Sync Processor — Full Flow

+ +

This is the most complex processor. It renders the description, checks if anything changed, checks quota availability, and only then calls YouTube's API.

+ +
@Processor(QUEUES.YOUTUBE_SYNC, {
+  concurrency: 1,                          // one sync at a time per worker
+  limiter: { max: 5, duration: 10_000 },   // max 5 jobs per 10 seconds
+})
+export class YouTubeSyncProcessor extends WorkerHost {
+
+  async process(job: Job<{ videoId: string; userId: string }>) {
+    const { videoId, userId } = job.data;  // userId identifies whose OAuth token to use
+
+    // Step 1: Load everything needed for rendering
+    const video = await prisma.video.findUniqueOrThrow({
+      where: { id: videoId },
+      include: { config: true, collaborators: { include: { collaborator: true } } },
+    });
+
+    // Step 2: Render the description
+    const renderResult = renderEngine.render({ ... });
+
+    // Step 3: Hash the metadata and compare with last synced state
+    const newHash = hashMetadata({ title: video.title, description: renderResult.rendered, ... });
+    if (!hasChanged(video.lastSyncedHash, newHash)) {
+      return { skipped: true, reason: 'no-change' };   // ✓ saves 50 quota units
+    }
+
+    // Step 4: Check YouTube API quota
+    if (!(await quota.canSpend(50))) {
+      // Delay the job until quota resets (midnight Pacific Time)
+      await job.moveToDelayed(Date.now() + quota.msUntilQuotaReset());
+      return { deferred: true };
+    }
+
+    // Step 5: Push to YouTube using the triggering user's OAuth token (costs 50 units)
+    await ytSync.pushUpdate(video, { title, description: renderResult.rendered, ... }, userId);
+
+    // Step 6: Record the quota spend
+    await quota.spend(50, 'videos.update', videoId);
+
+    // Step 7: Save the hash so we can skip next time if nothing changed
+    await prisma.video.update({
+      where: { id: videoId },
+      data: { lastSyncedHash: newHash, lastSyncedAt: new Date(), remoteConflict: false },
+    });
+  }
+}
+ + +

Bulk Metadata Processor

+ +

Each bulk job item (one video in a batch operation) becomes its own queue job. The processor applies the action, records before/after snapshots, and updates the parent BulkJob's counters.

+ +
async process(job: Job<{ bulkJobId: string; itemId: string }>) {
+  const item = await prisma.bulkJobItem.findUniqueOrThrow({
+    where: { id: itemId },
+    include: { bulkJob: true, video: true },
+  });
+
+  const before = { title: video.title, tags: video.tags, privacyStatus: video.privacyStatus };
+
+  try {
+    const after = this.applyAction(before, bulkJob.type, bulkJob.filterSnapshot);
+
+    await prisma.$transaction([
+      prisma.video.update({ where: { id: video.id }, data: after }),
+      prisma.bulkJobItem.update({
+        where: { id: itemId },
+        data: { status: 'done', beforeSnapshot: before, afterSnapshot: after },
+      }),
+      prisma.bulkJob.update({
+        where: { id: bulkJobId },
+        data: { successCount: { increment: 1 } },  // atomic increment
+      }),
+    ]);
+  } catch (err) {
+    // Record the failure without crashing the whole batch
+    await prisma.$transaction([
+      prisma.bulkJobItem.update({ where: { id: itemId }, data: { status: 'error', errorMessage: err.message } }),
+      prisma.bulkJob.update({ where: { id: bulkJobId }, data: { errorCount: { increment: 1 } } }),
+    ]);
+  }
+
+  // Check if all items are done — if so, mark the parent job complete
+  const pending = await prisma.bulkJobItem.count({ where: { bulkJobId, status: 'pending' } });
+  if (pending === 0) {
+    const errors = await prisma.bulkJobItem.count({ where: { bulkJobId, status: 'error' } });
+    await prisma.bulkJob.update({
+      where: { id: bulkJobId },
+      data: { status: errors > 0 ? 'FAILED' : 'DONE', completedAt: new Date() },
+    });
+  }
+}
+ + +

Render → Lint Job Chaining

+ +

The RenderProcessor automatically triggers a lint job when rendering finishes. This chaining keeps the two concerns (rendering and quality-checking) separate while ensuring lint always runs after a render.

+ +
@Processor(QUEUES.RENDER)
+export class RenderProcessor extends WorkerHost {
+  constructor(
+    private readonly prisma: PrismaService,
+    private readonly renderEngine: RenderEngineService,
+    @InjectQueue(QUEUES.LINT) private readonly lintQueue: Queue,
+    // ↑ This processor is ALSO a producer of lint jobs
+  ) { super(); }
+
+  async process(job: Job<{ videoId: string }>) {
+    // ... render and save to DB ...
+
+    // Chain: enqueue a lint job immediately after render completes
+    await this.lintQueue.add('lint', { videoId }, {
+      jobId: `lint-${videoId}-after-render`
+      // Named jobId prevents duplicate lint jobs if render fires multiple times quickly
+    });
+
+    return { rendered: true };
+  }
+}
+ + +

YouTubeApiClient — OAuth-based YouTube Access

+ +

All YouTube API calls use the logged-in user's OAuth token, stored encrypted in the database after the Google login flow. There is no separate YouTube API key — the YouTube Data API v3 scope is requested at login time, so the user's access token already grants full read/write access to their channel.

+ +
+ Why no API key? A standalone API key only allows unauthenticated read access to public data. Since StudioFlow writes video metadata (titles, descriptions, tags), it needs OAuth — and once you have OAuth, the API key is redundant. Removing it simplifies config and reduces secrets to manage. +
+ +
private async getYouTubeClient(userId: string) {
+  const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
+
+  // OAuth2 client needs client ID + secret so it can auto-refresh expired tokens
+  const oauth2 = new google.auth.OAuth2(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
+
+  oauth2.setCredentials({
+    access_token: authService.decryptToken(user.youtubeAccessToken),
+    refresh_token: authService.decryptToken(user.youtubeRefreshToken),
+  });
+
+  // When Google silently rotates the tokens, persist the new ones back to DB
+  oauth2.on('tokens', async (tokens) => {
+    await prisma.user.update({
+      where: { id: userId },
+      data: {
+        youtubeAccessToken: authService.encryptToken(tokens.access_token),
+        youtubeTokenExpiry: new Date(tokens.expiry_date),
+        // refresh_token only included when Google issues a new one
+        ...(tokens.refresh_token && {
+          youtubeRefreshToken: authService.encryptToken(tokens.refresh_token),
+        }),
+      },
+    });
+  });
+
+  return google.youtube({ version: 'v3', auth: oauth2 });
+}
+ +

The 'tokens' event fires automatically whenever the Google OAuth2 client performs a token refresh. By persisting the new tokens back to the database, the user never needs to log in again unless they explicitly revoke access.

+ +

Every public method on YouTubeApiClient now requires a userId parameter, which flows from the HTTP request through the queue job payload all the way to this method:

+ +
// Controller — user triggers sync via HTTP
+sync(@Param('id') id: string, @Req() req: any) {
+  return this.service.enqueueSyncJob(id, req.user.sub);   // sub = user's DB id from JWT
+}
+
+// Service — puts userId into the queue job payload
+async enqueueSyncJob(videoId: string, userId: string) {
+  await syncQueue.add('sync', { videoId, userId });
+}
+
+// Processor — reads userId from job and passes it to the API client
+async process(job: Job<{ videoId: string; userId: string }>) {
+  const { videoId, userId } = job.data;
+  // ... render, hash check, quota check ...
+  await ytSync.pushUpdate(video, meta, userId);   // userId carried through
+}
+ +

YouTube Quota Management

+ +

YouTube's Data API v3 has a daily quota of 10,000 units (this project uses 9,000 to leave a buffer). Different API operations cost different amounts:

+ + + + + + + +
OperationCostMethod called
videos.list (read metadata)1 unitYouTubeSyncService.fetchRemote()
videos.update (write metadata)50 unitsYouTubeSyncService.pushUpdate()
+ +

QuotaService design

+
@Injectable()
+export class QuotaService {
+  private readonly DAILY_LIMIT = 9_000;
+
+  // Checks if we can afford to spend `units` without exceeding the daily limit
+  async canSpend(units: number): Promise<boolean> {
+    return (await this.getTodayUsage()) + units <= this.DAILY_LIMIT;
+  }
+
+  // Records a quota spend in the database
+  async spend(units: number, operation: string, videoId?: string): Promise<void> {
+    await this.prisma.quotaLog.create({
+      data: { units, operation, videoId, datePt: this.getTodayPT() }
+    });
+  }
+
+  // Sums all quota used today (Pacific Time day)
+  async getTodayUsage(): Promise<number> {
+    const result = await this.prisma.quotaLog.aggregate({
+      _sum: { units: true },
+      where: { datePt: this.getTodayPT() },   // only today's rows
+    });
+    return result._sum.units ?? 0;
+  }
+
+  // Returns ms until midnight Pacific Time (YouTube's reset hour)
+  msUntilQuotaReset(): number {
+    const PT_OFFSET_MS = 8 * 60 * 60 * 1000;   // UTC-8 (PST)
+    const nowPT = new Date(Date.now() - PT_OFFSET_MS);
+    const tomorrowMidnightPT = new Date(nowPT);
+    tomorrowMidnightPT.setUTCHours(24, 0, 0, 0);
+    return tomorrowMidnightPT.getTime() - nowPT.getTime();
+  }
+}
+ + +

Videos Module — Full API

+ + + + + + + + + + + + +
MethodPathRoleDescription
GET/videosREADONLY+Paginated video list with filters: search, lintStatus, privacyStatus, channelId, templateId, collaboratorId, from, to, sort, order
GET/videos/:idREADONLY+Single video with full includes: config, collaborators, lintResults
PATCH/videos/:idEDITOR+Partial update: title, tags, privacyStatus, scheduledAt, categoryId, templateId
POST/videos/bulk-previewEDITOR+Dry-run a bulk action — returns before/after diffs per video, no writes
POST/videos/bulk-applyEDITOR+Creates a BulkJob and enqueues one job per video
POST/videos/:id/renderEDITOR+Synchronously renders description and saves to DB
POST/videos/:id/syncEDITOR+Enqueues a youtube-sync job for this video
+ +

Bulk action types

+ + + + + + + + + +
TypePayloadWhat it does
SET_PRIVACY{ privacyStatus: "PUBLIC" }Changes privacy status on all target videos
SET_TEMPLATE{ templateId: "abc" }Assigns a template to all target videos
ADD_TAGS{ tags: ["tutorial"] }Appends tags to each video's tag array
REMOVE_TAGS{ tags: ["old-tag"] }Removes specified tags from each video
SEARCH_REPLACE_TITLE{ search: "2024", replace: "2025" }String replace in each video's title
+ + +

Blocks & Automatic Versioning

+ +

Every time a block is edited, the system automatically creates a BlockVersion snapshot of the block's state before the change. This gives you a full edit history you can browse and roll back to.

+ +
// src/modules/blocks/blocks.service.ts — update() method
+async update(id: string, dto: UpdateBlockDto, actorId: string) {
+  // 1. Load the CURRENT state before changing anything
+  const before = await this.prisma.descriptionBlock.findUnique({ where: { id } });
+
+  // 2. Snapshot the current state into BlockVersion
+  await this.prisma.blockVersion.create({
+    data: {
+      blockId: id,
+      version: before.version,            // version number at time of snapshot
+      contentSnapshot: before as any,    // full block object stored as JSON
+      createdBy: actorId,
+    },
+  });
+
+  // 3. Apply the changes and increment the version counter
+  const updated = await this.prisma.descriptionBlock.update({
+    where: { id },
+    data: { ...dto, version: { increment: 1 } },  // atomic increment in DB
+  });
+
+  // 4. Write an audit log entry
+  await this.audit.log(actorId, 'DescriptionBlock', id, 'update', before, updated);
+  return updated;
+}
+ +

Block usage lookup

+
// "Which videos and templates use this block?"
+// Uses Prisma's JSON array_contains filter on JSONB columns
+async getUsage(id: string) {
+  const [videoConfigs, templates] = await Promise.all([
+    prisma.videoConfig.findMany({
+      where: { blockOrder: { array_contains: id } },   // JSON array contains this ID
+      select: { videoId: true },
+    }),
+    prisma.template.findMany({
+      where: { defaultBlocks: { array_contains: id } },
+      select: { id: true, name: true },
+    }),
+  ]);
+  return { videoIds: videoConfigs.map((c) => c.videoId), templates };
+}
+ + +

Bulk Jobs — Rollback

+ +

Every bulk job item stores a beforeSnapshot of the video's state before the change. The rollback endpoint uses these snapshots to restore each video to its pre-operation state.

+ +
// src/modules/bulk-jobs/bulk-jobs.service.ts
+async rollback(id: string, actorId: string) {
+  const job = await prisma.bulkJob.findUniqueOrThrow({
+    where: { id },
+    include: { items: { where: { status: 'done' } } },   // only items that succeeded
+  });
+
+  for (const item of job.items) {
+    if (!item.beforeSnapshot) continue;
+
+    // Restore the video to its state before the bulk action
+    await prisma.video.update({
+      where: { id: item.videoId },
+      data: item.beforeSnapshot as any,   // { title, tags, privacyStatus, ... }
+    });
+    await prisma.bulkJobItem.update({
+      where: { id: item.id },
+      data: { status: 'rolled_back' },
+    });
+  }
+
+  return prisma.bulkJob.update({ where: { id }, data: { status: 'ROLLED_BACK' } });
+}
+ + +

Import & Export

+ +

CSV Import — two-step process

+

Imports use a preview-then-commit pattern. The user uploads a CSV first, the server validates it and stores a report, then the user explicitly commits. This prevents partial imports from bad data.

+ +
// Step 1: POST /imports/csv/preview — validates rows with Zod
+const CsvRowSchema = z.object({
+  youtube_video_id: z.string().min(1),
+  title: z.string().optional(),
+  privacy_status: z.enum(['PUBLIC', 'PRIVATE', 'UNLISTED']).optional(),
+  // ... other fields
+});
+
+// csv-parse/sync parses the uploaded file buffer into row objects
+const rows = parse(fileBuffer, { columns: true, skip_empty_lines: true, trim: true });
+
+rows.forEach((row, i) => {
+  const result = CsvRowSchema.safeParse(row);
+  if (result.success) {
+    validRows.push(result.data);
+  } else {
+    // Zod's error contains field path + message
+    result.error.errors.forEach((e) =>
+      errors.push({ row: i + 1, field: e.path.join('.'), message: e.message })
+    );
+  }
+});
+
+// Returns: { importJobId, validRows: 47, errors: [{ row: 3, field: 'privacy_status', message: ... }] }
+
+// Step 2: POST /imports/csv/commit — enqueues the actual DB upserts
+await importQueue.add('import', { importJobId });
+ +

JSON Export

+
// POST /exports/json — full workspace snapshot
+async exportJson() {
+  // All 6 resource types fetched in parallel
+  const [videos, videoConfigs, blocks, templates, collaborators, savedViews] =
+    await Promise.all([
+      prisma.video.findMany(),
+      prisma.videoConfig.findMany(),
+      prisma.descriptionBlock.findMany(),
+      prisma.template.findMany(),
+      prisma.collaborator.findMany(),
+      prisma.savedView.findMany(),
+    ]);
+
+  return {
+    version: '1.0',
+    exportedAt: new Date().toISOString(),
+    videos, videoConfigs, blocks, templates, collaborators, savedViews,
+  };
+}
+ + +

Calendar Endpoint

+ +

The calendar endpoint returns videos that fall within a date range, shaped as calendar entries the frontend can display.

+ +
// GET /calendar?view=month&date=2026-04
+// view: 'month' | 'week' | 'agenda'
+// date: 'YYYY-MM' for month/week, 'YYYY-MM-DD' for agenda
+
+private parseRange(view: string, date: string): { start: Date; end: Date } {
+  const [year, month] = date.split('-').map(Number);
+
+  if (view === 'month') {
+    return {
+      start: new Date(year, month - 1, 1),         // first of month
+      end: new Date(year, month, 0, 23, 59, 59),  // last day (day 0 of next month)
+    };
+  }
+  // 'agenda': next 30 days from the given date
+  const start = new Date(date);
+  const end = new Date(start);
+  end.setDate(start.getDate() + 30);
+  return { start, end };
+}
+
+// Returns videos scheduled or published in the range
+where: {
+  OR: [
+    { scheduledAt: { gte: start, lte: end } },
+    { publishedAt: { gte: start, lte: end } },
+  ],
+}
+ + +

Audit & Quota HTTP Endpoints

+ + + + + + + + +
MethodPathResponse
GET/quota/today{ used: 1250, remaining: 7750, limit: 9000, resetAt: "...", percentUsed: 14 }
GET/audit-logs?page=1&limit=50Paginated list of all audit log entries, newest first
GET/audit-logs/:entityType/:entityIdAll audit entries for a specific entity (e.g. /audit-logs/Video/abc123)
+ +

The AuditService is called by every write operation in the codebase to create a tamper-evident log of who changed what:

+ +
// Every update operation follows this pattern:
+const before = await this.findOne(id);                    // snapshot before
+const after = await prisma.video.update({ where: { id }, data });  // apply change
+await this.audit.log(actorId, 'Video', id, 'update', before, after);
+ + +

Environment Variables

+ +

Copy .env.example to .env and fill in these values before running anything:

+ + + + + + + + + + + + + + + + +
VariableExample valuePurpose
DATABASE_URLpostgresql://user:pass@localhost:5432/studioflowPostgreSQL connection string
REDIS_URLredis://:password@localhost:6379Redis connection for BullMQ
JWT_SECRETat-least-32-random-charsSigns access tokens (15 min)
JWT_REFRESH_SECRETdifferent-32-random-charsSigns refresh tokens (7 days)
GOOGLE_CLIENT_ID123456.apps.googleusercontent.comFrom Google Cloud Console
GOOGLE_CLIENT_SECRETGOCSPX-...From Google Cloud Console
GOOGLE_CALLBACK_URLhttp://localhost:3001/api/v1/auth/google/callbackMust match Google OAuth config
TOKEN_ENCRYPTION_KEYexactly-32-random-chars-here-xxAES-256 key for YouTube token encryption
PORT3001HTTP server port
FRONTEND_URLhttp://localhost:3000CORS origin + OAuth redirect target
NODE_ENVdevelopmentSet to production to enable secure cookies
+ + +

Getting Started

+ +

Prerequisites

+ + +

First-time setup

+
# 1. Install dependencies
+npm install
+
+# 2. Copy environment variables
+cp .env.example .env
+# Edit .env and fill in all values
+
+# 3. Generate Prisma client (must run after every schema.prisma change)
+npx prisma generate
+
+# 4. Create database tables
+npx prisma migrate dev --name init
+
+# 5. Verify the TypeScript compiles with no errors
+npm run build
+ +

Development (two terminals)

+
# Terminal 1 — HTTP API server with hot reload
+npm run start:dev
+
+# Terminal 2 — Background worker with hot reload
+npm run start:worker
+ +

Verify it works

+
# Health check — should return {"status":"ok"}
+curl http://localhost:3001/api/v1/health
+
+# Swagger UI — interactive API documentation
+# Open in browser:
+http://localhost:3001/api/docs
+ +

Useful Prisma commands

+
# Open Prisma Studio — GUI to browse and edit the database
+npx prisma studio
+
+# Create a new migration after changing schema.prisma
+npx prisma migrate dev --name describe-your-change
+
+# Apply pending migrations in production (no interactive prompt)
+npx prisma migrate deploy
+
+# Reset the database (drops all tables and recreates)
+npx prisma migrate reset
+ + +

All API Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathModuleMin Role
GET/healthHealthPublic
GET/auth/googleAuthPublic
GET/auth/google/callbackAuthPublic
GET/auth/meAuthAny JWT
POST/auth/refreshAuthCookie
GET/videosVideosREADONLY
GET/videos/:idVideosREADONLY
PATCH/videos/:idVideosEDITOR
POST/videos/bulk-previewVideosEDITOR
POST/videos/bulk-applyVideosEDITOR
POST/videos/:id/renderVideosEDITOR
POST/videos/:id/syncVideosEDITOR
GET/blocksBlocksREADONLY
POST/blocksBlocksEDITOR
PATCH/blocks/:idBlocksEDITOR
GET/blocks/:id/versionsBlocksREADONLY
GET/blocks/:id/usageBlocksREADONLY
GET/templatesTemplatesREADONLY
POST/templatesTemplatesEDITOR
PATCH/templates/:idTemplatesEDITOR
POST/templates/:id/render-previewTemplatesEDITOR
GET/video-configs/:videoIdVideoConfigsREADONLY
PUT/video-configs/:videoIdVideoConfigsEDITOR
POST/video-configs/:videoId/render-previewVideoConfigsREADONLY
GET/collaboratorsCollaboratorsREADONLY
POST/collaboratorsCollaboratorsEDITOR
PATCH/collaborators/:idCollaboratorsEDITOR
GET/collaborators/:id/videosCollaboratorsREADONLY
POST/videos/:id/collaboratorsCollaboratorsEDITOR
DELETE/videos/:id/collaborators/:cIdCollaboratorsEDITOR
GET/bulk-jobsBulkJobsREADONLY
GET/bulk-jobs/:idBulkJobsREADONLY
POST/bulk-jobs/:id/rollbackBulkJobsEDITOR
GET/saved-viewsSavedViewsREADONLY
POST/saved-viewsSavedViewsEDITOR
PATCH/saved-views/:idSavedViewsEDITOR
DELETE/saved-views/:idSavedViewsADMIN
POST/saved-views/:id/executeSavedViewsREADONLY
POST/lint/videos/:idLintingEDITOR
POST/lint/bulkLintingEDITOR
GET/lint/resultsLintingREADONLY
GET/calendarCalendarREADONLY
POST/imports/csv/previewImportsEDITOR
POST/imports/csv/commitImportsEDITOR
POST/imports/json/previewImportsEDITOR
POST/imports/json/commitImportsEDITOR
POST/exports/csvExportsREADONLY
POST/exports/jsonExportsREADONLY
GET/quota/todayQuotaREADONLY
GET/audit-logsAuditLogsREADONLY
GET/audit-logs/:entityType/:entityIdAuditLogsREADONLY
+ +

All routes (except /health, /auth/google, /auth/google/callback, and /auth/refresh) require a valid JWT in the Authorization: Bearer <token> header. The interactive Swagger UI at /api/docs includes an "Authorize" button to paste your token and test all endpoints directly.

+ + + +
+ + + + diff --git a/backend/nest-cli.json b/backend/nest-cli.json new file mode 100644 index 0000000..e769c18 --- /dev/null +++ b/backend/nest-cli.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "entryFile": "main", + "compilerOptions": { + "deleteOutDir": true, + "assets": [], + "watchAssets": false + } +} diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..9073bb6 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,9993 @@ +{ + "name": "studioflow-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "studioflow-backend", + "version": "1.0.0", + "dependencies": { + "@nestjs/bullmq": "^11.0.0", + "@nestjs/common": "^11.0.0", + "@nestjs/config": "^4.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/jwt": "^11.0.0", + "@nestjs/passport": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/swagger": "^11.0.0", + "@prisma/client": "^6.0.0", + "bullmq": "^5.4.2", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "cookie-parser": "^1.4.6", + "csv-parse": "^5.5.5", + "csv-stringify": "^6.4.6", + "googleapis": "^171.0.0", + "ioredis": "^5.3.2", + "multer": "^2.1.1", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.2.1", + "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.0", + "@types/cookie-parser": "^1.4.7", + "@types/express": "^5.0.0", + "@types/multer": "^1.4.11", + "@types/node": "^22.0.0", + "@types/passport-google-oauth20": "^2.0.14", + "@types/passport-jwt": "^4.0.1", + "prisma": "^6.0.0", + "ts-jest": "^29.1.2", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.3.3" + } + }, + "node_modules/@angular-devkit/core": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.24.tgz", + "integrity": "sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/core/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/core/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.24.tgz", + "integrity": "sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.24.tgz", + "integrity": "sha512-bsStZQG67J1HBqTmWxtIcobvgrn32L4UOdL7hGyOru5VxDWPNA8pRnDYavT3hnJeBkJYPoQIw8u7Dm0ecoQprw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "@inquirer/prompts": "7.3.2", + "ansi-colors": "4.1.3", + "symbol-observable": "4.0.0", + "yargs-parser": "21.1.1" + }, + "bin": { + "schematics": "bin/schematics.js" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", + "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.1.2", + "@inquirer/confirm": "^5.1.6", + "@inquirer/editor": "^4.2.7", + "@inquirer/expand": "^4.0.9", + "@inquirer/input": "^4.1.6", + "@inquirer/number": "^3.0.9", + "@inquirer/password": "^4.0.9", + "@inquirer/rawlist": "^4.0.9", + "@inquirer/search": "^3.0.9", + "@inquirer/select": "^4.0.9" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "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, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", + "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", + "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/console": "30.3.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.3.0", + "jest-config": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-resolve-dependencies": "30.3.0", + "jest-runner": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "jest-watcher": "30.3.0", + "pretty-format": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", + "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-mock": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "expect": "30.3.0", + "jest-snapshot": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", + "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", + "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", + "@types/node": "*", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", + "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/types": "30.3.0", + "jest-mock": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz", + "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", + "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz", + "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/console": "30.3.0", + "@jest/types": "30.3.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", + "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/test-result": "30.3.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", + "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.3.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.3.0", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "license": "MIT" + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@nestjs/bull-shared": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.4.tgz", + "integrity": "sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/bullmq": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-11.0.4.tgz", + "integrity": "sha512-wBzK9raAVG0/6NTMdvLGM4/FQ1lsB35/pYS8L6a0SDgkTiLpd7mAjQ8R692oMx5s7IjvgntaZOuTUrKYLNfIkA==", + "license": "MIT", + "dependencies": { + "@nestjs/bull-shared": "^11.0.4", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "bullmq": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, + "node_modules/@nestjs/cli": { + "version": "11.0.21", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.21.tgz", + "integrity": "sha512-F8mV0Sj/zVEouzR3NxBuJy08YHTUOmC5Xdcx3qIIaJWzrm8Vw86CHkhkaPBJ5ewRMHPDCShPmhsfwhpCcjts3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "@angular-devkit/schematics-cli": "19.2.24", + "@inquirer/prompts": "7.10.1", + "@nestjs/schematics": "^11.0.1", + "ansis": "4.2.0", + "chokidar": "4.0.3", + "cli-table3": "0.6.5", + "commander": "4.1.1", + "fork-ts-checker-webpack-plugin": "9.1.0", + "glob": "13.0.6", + "node-emoji": "1.11.0", + "ora": "5.4.1", + "tsconfig-paths": "4.2.0", + "tsconfig-paths-webpack-plugin": "4.2.0", + "typescript": "5.9.3", + "webpack": "5.106.0", + "webpack-node-externals": "3.0.0" + }, + "bin": { + "nest": "bin/nest.js" + }, + "engines": { + "node": ">= 20.11" + }, + "peerDependencies": { + "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0", + "@swc/core": "^1.3.62" + }, + "peerDependenciesMeta": { + "@swc/cli": { + "optional": true + }, + "@swc/core": { + "optional": true + } + } + }, + "node_modules/@nestjs/cli/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@nestjs/cli/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@nestjs/cli/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@nestjs/cli/node_modules/webpack": { + "version": "5.106.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.0.tgz", + "integrity": "sha512-Pkx5joZ9RrdgO5LBkyX1L2ZAJeK/Taz3vqZ9CbcP0wS5LEMx5QkKsEwLl29QJfihZ+DKRBFldzy1O30pJ1MDpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/@nestjs/common": { + "version": "11.1.19", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.19.tgz", + "integrity": "sha512-qeiTt2tv+e5QyDKqG8HlVZb2wx64FEaSGFJouqTSRs+kG44iTfl3xlz1XqVped+rihx4hmjWgL5gkhtdK3E6+Q==", + "license": "MIT", + "dependencies": { + "file-type": "21.3.4", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/config": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz", + "integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==", + "license": "MIT", + "dependencies": { + "dotenv": "17.4.1", + "dotenv-expand": "12.0.3", + "lodash": "4.18.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/core": { + "version": "11.1.19", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.19.tgz", + "integrity": "sha512-6nJkWa2efrYi+XlU686J9y5L7OvxpLVjT0T/sxRKE7Jvpffiihelup4WSvLvRhdHDjj/5SuoWEwqReXAaaeHmw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@nuxt/opencollective": "0.4.1", + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/jwt": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/mapped-types": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz", + "integrity": "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/passport": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, + "node_modules/@nestjs/platform-express": { + "version": "11.1.19", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.19.tgz", + "integrity": "sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg==", + "license": "MIT", + "dependencies": { + "cors": "2.8.6", + "express": "5.2.1", + "multer": "2.1.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0" + } + }, + "node_modules/@nestjs/schematics": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", + "integrity": "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "comment-json": "5.0.0", + "jsonc-parser": "3.3.1", + "pluralize": "8.0.0" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "typescript": ">=4.8.2" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/@nestjs/swagger": { + "version": "11.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.2.tgz", + "integrity": "sha512-aBihEogDMj/bLEcaqhkvyX/ZVWUw/bmnhKzR0zwUoyGJikvZyaq7rOPYl/H7Lxkkr3c90SJxyuv1AX2UT1WKlw==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "@nestjs/mapped-types": "2.1.1", + "js-yaml": "4.1.1", + "lodash": "4.18.1", + "path-to-regexp": "8.4.2", + "swagger-ui-dist": "5.32.4" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0 || ^9.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/testing": { + "version": "11.1.19", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.19.tgz", + "integrity": "sha512-/UFNWXvPEdu4v4DlC5oWLbGKmD27LehLK06b8oLzs6D6lf4vAQTdST8LRAXBadyMUQnVEQWMuBo3CtAVtlfXtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + } + } + }, + "node_modules/@nuxt/opencollective": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz", + "integrity": "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + }, + "bin": { + "opencollective": "bin/opencollective.js" + }, + "engines": { + "node": "^14.18.0 || >=16.10.0", + "npm": ">=5.10.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.2.tgz", + "integrity": "sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "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, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz", + "integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/oauth": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz", + "integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-google-oauth20": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz", + "integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", + "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/transform": "30.3.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.3.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz", + "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz", + "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "babel-plugin-jest-hoist": "30.3.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "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==", + "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/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", + "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "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": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "node-int64": "^0.4.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/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bullmq": { + "version": "5.76.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.76.2.tgz", + "integrity": "sha512-kkNU6TPAjqV3Ep0kIaYhT79Z2IMoA7vadqjmr/zvmPicg0K/cOAecqZTihD726LbI043yPU0MBv/nMQmd5rNIg==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "1.11.5", + "node-abort-controller": "3.1.1", + "semver": "7.7.4", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", + "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/comment-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-5.0.0.tgz", + "integrity": "sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csv-parse": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz", + "integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==", + "license": "MIT" + }, + "node_modules/csv-stringify": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.7.0.tgz", + "integrity": "sha512-UdtziYp5HuTz7e5j8Nvq+a/3HQo+2/aJZ9xntNTpmRRIg/3YYqDVgiS9fvAhtNbnyfbv2ZBe0bqCHqzhE7FqWQ==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/expect-utils": "30.3.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/express/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/express/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-check/node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", + "integrity": "sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.16.7", + "chalk": "^4.1.2", + "chokidar": "^4.0.1", + "cosmiconfig": "^8.2.0", + "deepmerge": "^4.2.2", + "fs-extra": "^10.0.0", + "memfs": "^3.4.1", + "minimatch": "^3.0.4", + "node-abort-controller": "^3.0.1", + "schema-utils": "^3.1.1", + "semver": "^7.3.5", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "typescript": ">3.6.0", + "webpack": "^5.11.0" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", + "integrity": "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/fsevents": { + "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, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "peer": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "171.4.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-171.4.0.tgz", + "integrity": "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.2.0", + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz", + "integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^7.0.0-rc.4", + "google-auth-library": "^10.1.0", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.3.0.tgz", + "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/core": "30.3.0", + "@jest/types": "30.3.0", + "import-local": "^3.2.0", + "jest-cli": "30.3.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.3.0.tgz", + "integrity": "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.3.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.3.0.tgz", + "integrity": "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "p-limit": "^3.1.0", + "pretty-format": "30.3.0", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.3.0.tgz", + "integrity": "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/core": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.3.0.tgz", + "integrity": "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.3.0", + "@jest/types": "30.3.0", + "babel-jest": "30.3.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.3.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-runner": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "parse-json": "^5.2.0", + "pretty-format": "30.3.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-diff": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/diff-sequences": "30.3.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.3.0.tgz", + "integrity": "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "jest-util": "30.3.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz", + "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-mock": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", + "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", + "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", + "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.3.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.3.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.3.0.tgz", + "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.3.0.tgz", + "integrity": "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.3.0.tgz", + "integrity": "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/console": "30.3.0", + "@jest/environment": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-leak-detector": "30.3.0", + "jest-message-util": "30.3.0", + "jest-resolve": "30.3.0", + "jest-runtime": "30.3.0", + "jest-util": "30.3.0", + "jest-watcher": "30.3.0", + "jest-worker": "30.3.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.3.0.tgz", + "integrity": "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/globals": "30.3.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-snapshot": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", + "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.3.0", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.3.0", + "graceful-fs": "^4.2.11", + "jest-diff": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "pretty-format": "30.3.0", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", + "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.3.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.3.0.tgz", + "integrity": "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.3.0", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", + "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.3.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.12.42", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.42.tgz", + "integrity": "sha512-oKQFPTibqQwZZkChCDVMFVJXMZdyJNqDWZWYNn8BgyAaK/6yFJEowxCY0RVFirRyWP63hMRuKlkSEd9qlvbWXg==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-esm": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", + "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-emoji": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", + "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nypm": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz", + "integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.1.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0", + "peer": true + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pretty-format": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "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==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "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==", + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.32.4", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.4.tgz", + "integrity": "sha512-0AADFFQNJzExEN49SrD/34Nn9cxNxVLiydYl2MBwSZFPVXNkVwC/EFAjoezGGqE8oDegiDC+p47t8lKObCinMQ==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.2.tgz", + "integrity": "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.5.0.tgz", + "integrity": "sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/terser/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/terser/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/ts-jest": { + "version": "29.4.9", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", + "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.4", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-loader": { + "version": "9.5.7", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.7.tgz", + "integrity": "sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", + "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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==", + "license": "0BSD" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, + "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==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webpack": { + "version": "5.106.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", + "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.1", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-node-externals": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", + "integrity": "sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/webpack-sources": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.0.tgz", + "integrity": "sha512-gHwIe1cgBvvfLeu1Yz/dcFpmHfKDVxxyqI+kzqmuxZED81z2ChxpyqPaWcNqigPywhaEke7AjSGga+kxY55gjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "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==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..c278a23 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,72 @@ +{ + "name": "studioflow-backend", + "version": "1.0.0", + "description": "StudioFlow API & Worker", + "private": true, + "scripts": { + "build": "nest build", + "start": "node dist/main.js", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:worker": "nest start --entryFile worker --watch", + "start:worker:prod": "node dist/worker.js", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "make-admin": "ts-node -e \"\" prisma/make-admin.ts" + }, + "dependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/config": "^4.0.0", + "@nestjs/jwt": "^11.0.0", + "@nestjs/passport": "^11.0.0", + "@nestjs/swagger": "^11.0.0", + "@nestjs/bullmq": "^11.0.0", + "bullmq": "^5.4.2", + "ioredis": "^5.3.2", + "@prisma/client": "^6.0.0", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", + "class-validator": "^0.14.1", + "class-transformer": "^0.5.1", + "zod": "^3.22.4", + "csv-parse": "^5.5.5", + "csv-stringify": "^6.4.6", + "multer": "^2.1.1", + "googleapis": "^171.0.0", + "reflect-metadata": "^0.2.1", + "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.0", + "cookie-parser": "^1.4.6" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.0", + "@types/express": "^5.0.0", + "@types/multer": "^1.4.11", + "@types/node": "^22.0.0", + "@types/passport-google-oauth20": "^2.0.14", + "@types/passport-jwt": "^4.0.1", + "@types/cookie-parser": "^1.4.7", + "prisma": "^6.0.0", + "ts-jest": "^29.1.2", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.3.3" + }, + "jest": { + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "collectCoverageFrom": ["**/*.(t|j)s"], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/backend/prisma/make-admin.ts b/backend/prisma/make-admin.ts new file mode 100644 index 0000000..fbbea31 --- /dev/null +++ b/backend/prisma/make-admin.ts @@ -0,0 +1,27 @@ +/** + * Usage: npx ts-node prisma/make-admin.ts + * Promotes an existing user to app-level admin (isAppAdmin = true) by email. + * Run this once after the first Google sign-in. + */ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + const email = process.argv[2]; + if (!email) { + console.error('Usage: npx ts-node prisma/make-admin.ts '); + process.exit(1); + } + + const user = await prisma.user.update({ + where: { email }, + data: { isAppAdmin: true }, + }); + + console.log(`✓ ${user.name} (${user.email}) is now an app admin`); +} + +main() + .catch((e) => { console.error(e); process.exit(1); }) + .finally(() => prisma.$disconnect()); diff --git a/backend/prisma/migrations/20260429131107_init/migration.sql b/backend/prisma/migrations/20260429131107_init/migration.sql new file mode 100644 index 0000000..a3d545d --- /dev/null +++ b/backend/prisma/migrations/20260429131107_init/migration.sql @@ -0,0 +1,347 @@ +-- CreateEnum +CREATE TYPE "PrivacyStatus" AS ENUM ('PUBLIC', 'PRIVATE', 'UNLISTED'); + +-- CreateEnum +CREATE TYPE "LintStatus" AS ENUM ('OK', 'WARNING', 'ERROR'); + +-- CreateEnum +CREATE TYPE "BlockType" AS ENUM ('STATIC', 'VARIABLE', 'CONDITIONAL', 'REPEATABLE', 'GLOBAL', 'CAMPAIGN', 'COLLABORATOR'); + +-- CreateEnum +CREATE TYPE "LintSeverity" AS ENUM ('INFO', 'WARNING', 'ERROR'); + +-- CreateEnum +CREATE TYPE "BulkJobStatus" AS ENUM ('PENDING', 'DRY_RUN', 'CONFIRMED', 'RUNNING', 'DONE', 'FAILED', 'ROLLED_BACK'); + +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'EDITOR', 'REVIEWER', 'READONLY'); + +-- CreateTable +CREATE TABLE "Video" ( + "id" TEXT NOT NULL, + "youtubeVideoId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "renderedDescription" TEXT, + "tags" TEXT[], + "categoryId" TEXT, + "privacyStatus" "PrivacyStatus" NOT NULL DEFAULT 'PRIVATE', + "publishedAt" TIMESTAMP(3), + "scheduledAt" TIMESTAMP(3), + "templateId" TEXT, + "lintStatus" "LintStatus" NOT NULL DEFAULT 'OK', + "lastSyncedAt" TIMESTAMP(3), + "lastSyncedHash" TEXT, + "remoteConflict" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Video_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VideoConfig" ( + "id" TEXT NOT NULL, + "videoId" TEXT NOT NULL, + "templateId" TEXT, + "blockOrder" JSONB NOT NULL, + "blockOverrides" JSONB NOT NULL, + "variableValues" JSONB NOT NULL, + "collaboratorIds" JSONB NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "renderHash" TEXT, + "renderedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "VideoConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DescriptionBlock" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "type" "BlockType" NOT NULL, + "content" TEXT NOT NULL, + "language" TEXT NOT NULL DEFAULT 'de', + "campaignId" TEXT, + "version" INTEGER NOT NULL DEFAULT 1, + "active" BOOLEAN NOT NULL DEFAULT true, + "tags" TEXT[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DescriptionBlock_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BlockVersion" ( + "id" TEXT NOT NULL, + "blockId" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "contentSnapshot" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdBy" TEXT, + + CONSTRAINT "BlockVersion_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Template" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "defaultBlocks" JSONB NOT NULL, + "rules" JSONB NOT NULL, + "variables" JSONB NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Template_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TemplateVersion" ( + "id" TEXT NOT NULL, + "templateId" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "snapshot" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TemplateVersion_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Collaborator" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "youtubeHandle" TEXT NOT NULL, + "twitchLink" TEXT, + "aliases" TEXT[], + "active" BOOLEAN NOT NULL DEFAULT true, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Collaborator_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VideoCollaborator" ( + "videoId" TEXT NOT NULL, + "collaboratorId" TEXT NOT NULL, + "role" TEXT, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "VideoCollaborator_pkey" PRIMARY KEY ("videoId","collaboratorId") +); + +-- CreateTable +CREATE TABLE "SavedView" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "isGlobal" BOOLEAN NOT NULL DEFAULT false, + "ownerId" TEXT, + "queryJson" JSONB NOT NULL, + "columnsJson" JSONB NOT NULL, + "sortJson" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SavedView_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LintResult" ( + "id" TEXT NOT NULL, + "videoId" TEXT NOT NULL, + "ruleCode" TEXT NOT NULL, + "severity" "LintSeverity" NOT NULL, + "targetField" TEXT, + "message" TEXT NOT NULL, + "fixSuggestion" TEXT, + "resolvedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LintResult_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BulkJob" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "initiatedBy" TEXT NOT NULL, + "filterSnapshot" JSONB NOT NULL, + "targetIds" TEXT[], + "status" "BulkJobStatus" NOT NULL DEFAULT 'PENDING', + "dryRunResult" JSONB, + "rollbackData" JSONB, + "totalCount" INTEGER NOT NULL, + "successCount" INTEGER NOT NULL DEFAULT 0, + "errorCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completedAt" TIMESTAMP(3), + + CONSTRAINT "BulkJob_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BulkJobItem" ( + "id" TEXT NOT NULL, + "bulkJobId" TEXT NOT NULL, + "videoId" TEXT NOT NULL, + "beforeSnapshot" JSONB, + "afterSnapshot" JSONB, + "status" TEXT NOT NULL DEFAULT 'pending', + "errorMessage" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BulkJobItem_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Campaign" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "startAt" TIMESTAMP(3) NOT NULL, + "endAt" TIMESTAMP(3), + "status" TEXT NOT NULL DEFAULT 'active', + "notes" TEXT, + + CONSTRAINT "Campaign_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ImportJob" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "sourceName" TEXT NOT NULL, + "mappingJson" JSONB, + "validationReport" JSONB, + "commitStatus" TEXT NOT NULL DEFAULT 'pending', + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "committedAt" TIMESTAMP(3), + + CONSTRAINT "ImportJob_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ExportJob" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "scopeJson" JSONB NOT NULL, + "fileReference" TEXT, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ExportJob_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "QuotaLog" ( + "id" TEXT NOT NULL, + "datePt" TIMESTAMP(3) NOT NULL, + "units" INTEGER NOT NULL, + "operation" TEXT NOT NULL, + "entityId" TEXT, + "videoId" TEXT, + "bulkJobId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "QuotaLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuditLog" ( + "id" TEXT NOT NULL, + "actorId" TEXT NOT NULL, + "entityType" TEXT NOT NULL, + "entityId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "beforeJson" JSONB, + "afterJson" JSONB, + "requestId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT, + "googleId" TEXT NOT NULL, + "role" "UserRole" NOT NULL DEFAULT 'EDITOR', + "youtubeAccessToken" TEXT, + "youtubeRefreshToken" TEXT, + "youtubeTokenExpiry" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Video_youtubeVideoId_key" ON "Video"("youtubeVideoId"); + +-- CreateIndex +CREATE UNIQUE INDEX "VideoConfig_videoId_key" ON "VideoConfig"("videoId"); + +-- CreateIndex +CREATE INDEX "LintResult_videoId_severity_idx" ON "LintResult"("videoId", "severity"); + +-- CreateIndex +CREATE INDEX "LintResult_ruleCode_idx" ON "LintResult"("ruleCode"); + +-- CreateIndex +CREATE INDEX "QuotaLog_datePt_idx" ON "QuotaLog"("datePt"); + +-- CreateIndex +CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId"); + +-- CreateIndex +CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_googleId_key" ON "User"("googleId"); + +-- AddForeignKey +ALTER TABLE "Video" ADD CONSTRAINT "Video_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VideoConfig" ADD CONSTRAINT "VideoConfig_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DescriptionBlock" ADD CONSTRAINT "DescriptionBlock_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BlockVersion" ADD CONSTRAINT "BlockVersion_blockId_fkey" FOREIGN KEY ("blockId") REFERENCES "DescriptionBlock"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TemplateVersion" ADD CONSTRAINT "TemplateVersion_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VideoCollaborator" ADD CONSTRAINT "VideoCollaborator_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VideoCollaborator" ADD CONSTRAINT "VideoCollaborator_collaboratorId_fkey" FOREIGN KEY ("collaboratorId") REFERENCES "Collaborator"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LintResult" ADD CONSTRAINT "LintResult_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BulkJobItem" ADD CONSTRAINT "BulkJobItem_bulkJobId_fkey" FOREIGN KEY ("bulkJobId") REFERENCES "BulkJob"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BulkJobItem" ADD CONSTRAINT "BulkJobItem_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "QuotaLog" ADD CONSTRAINT "QuotaLog_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260521104943_add_multi_tenancy/migration.sql b/backend/prisma/migrations/20260521104943_add_multi_tenancy/migration.sql new file mode 100644 index 0000000..8fa0793 --- /dev/null +++ b/backend/prisma/migrations/20260521104943_add_multi_tenancy/migration.sql @@ -0,0 +1,175 @@ +/* + Warnings: + + - You are about to drop the column `role` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `youtubeAccessToken` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `youtubeRefreshToken` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `youtubeTokenExpiry` on the `User` table. All the data in the column will be lost. + - Added the required column `teamId` to the `BulkJob` table without a default value. This is not possible if the table is not empty. + - Added the required column `teamId` to the `Campaign` table without a default value. This is not possible if the table is not empty. + - Added the required column `teamId` to the `Collaborator` table without a default value. This is not possible if the table is not empty. + - Added the required column `teamId` to the `DescriptionBlock` table without a default value. This is not possible if the table is not empty. + - Added the required column `teamId` to the `ExportJob` table without a default value. This is not possible if the table is not empty. + - Added the required column `teamId` to the `ImportJob` table without a default value. This is not possible if the table is not empty. + - Added the required column `teamId` to the `SavedView` table without a default value. This is not possible if the table is not empty. + - Added the required column `teamId` to the `Template` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "TeamRole" AS ENUM ('OWNER', 'ADMIN', 'EDITOR', 'REVIEWER', 'READONLY'); + +-- AlterTable +ALTER TABLE "BulkJob" ADD COLUMN "teamId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "Campaign" ADD COLUMN "teamId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "Collaborator" ADD COLUMN "teamId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "DescriptionBlock" ADD COLUMN "teamId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ExportJob" ADD COLUMN "teamId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ImportJob" ADD COLUMN "teamId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "QuotaLog" ADD COLUMN "channelId" TEXT; + +-- AlterTable +ALTER TABLE "SavedView" ADD COLUMN "teamId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "Template" ADD COLUMN "teamId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "role", +DROP COLUMN "youtubeAccessToken", +DROP COLUMN "youtubeRefreshToken", +DROP COLUMN "youtubeTokenExpiry", +ADD COLUMN "isAppAdmin" BOOLEAN NOT NULL DEFAULT false; + +-- DropEnum +DROP TYPE "UserRole"; + +-- CreateTable +CREATE TABLE "Team" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Team_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamMember" ( + "userId" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "role" "TeamRole" NOT NULL DEFAULT 'EDITOR', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("userId","teamId") +); + +-- CreateTable +CREATE TABLE "Channel" ( + "id" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "youtubeChannelId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "uploadsPlaylistId" TEXT, + "youtubeAccessToken" TEXT, + "youtubeRefreshToken" TEXT, + "youtubeTokenExpiry" TIMESTAMP(3), + "connectedBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Channel_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Team_slug_key" ON "Team"("slug"); + +-- CreateIndex +CREATE INDEX "TeamMember_teamId_idx" ON "TeamMember"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Channel_youtubeChannelId_key" ON "Channel"("youtubeChannelId"); + +-- CreateIndex +CREATE INDEX "Channel_teamId_idx" ON "Channel"("teamId"); + +-- CreateIndex +CREATE INDEX "BulkJob_teamId_idx" ON "BulkJob"("teamId"); + +-- CreateIndex +CREATE INDEX "Campaign_teamId_idx" ON "Campaign"("teamId"); + +-- CreateIndex +CREATE INDEX "Collaborator_teamId_idx" ON "Collaborator"("teamId"); + +-- CreateIndex +CREATE INDEX "DescriptionBlock_teamId_idx" ON "DescriptionBlock"("teamId"); + +-- CreateIndex +CREATE INDEX "ExportJob_teamId_idx" ON "ExportJob"("teamId"); + +-- CreateIndex +CREATE INDEX "ImportJob_teamId_idx" ON "ImportJob"("teamId"); + +-- CreateIndex +CREATE INDEX "QuotaLog_channelId_idx" ON "QuotaLog"("channelId"); + +-- CreateIndex +CREATE INDEX "SavedView_teamId_idx" ON "SavedView"("teamId"); + +-- CreateIndex +CREATE INDEX "Template_teamId_idx" ON "Template"("teamId"); + +-- CreateIndex +CREATE INDEX "Video_channelId_idx" ON "Video"("channelId"); + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Channel" ADD CONSTRAINT "Channel_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Video" ADD CONSTRAINT "Video_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DescriptionBlock" ADD CONSTRAINT "DescriptionBlock_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Collaborator" ADD CONSTRAINT "Collaborator_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SavedView" ADD CONSTRAINT "SavedView_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BulkJob" ADD CONSTRAINT "BulkJob_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Campaign" ADD CONSTRAINT "Campaign_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ImportJob" ADD CONSTRAINT "ImportJob_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ExportJob" ADD CONSTRAINT "ExportJob_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "QuotaLog" ADD CONSTRAINT "QuotaLog_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260521132929_add_team_variables_and_block_var_defs/migration.sql b/backend/prisma/migrations/20260521132929_add_team_variables_and_block_var_defs/migration.sql new file mode 100644 index 0000000..6eefa01 --- /dev/null +++ b/backend/prisma/migrations/20260521132929_add_team_variables_and_block_var_defs/migration.sql @@ -0,0 +1,23 @@ +-- AlterTable +ALTER TABLE "DescriptionBlock" ADD COLUMN "variableDefinitions" JSONB NOT NULL DEFAULT '[]'; + +-- CreateTable +CREATE TABLE "TeamVariable" ( + "id" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "value" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TeamVariable_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "TeamVariable_teamId_idx" ON "TeamVariable"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamVariable_teamId_name_key" ON "TeamVariable"("teamId", "name"); + +-- AddForeignKey +ALTER TABLE "TeamVariable" ADD CONSTRAINT "TeamVariable_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260521134945_add_youtube_description/migration.sql b/backend/prisma/migrations/20260521134945_add_youtube_description/migration.sql new file mode 100644 index 0000000..7437507 --- /dev/null +++ b/backend/prisma/migrations/20260521134945_add_youtube_description/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Video" ADD COLUMN "youtubeDescription" TEXT; diff --git a/backend/prisma/migrations/20260521141202_add_video_extended_fields_and_playlists/migration.sql b/backend/prisma/migrations/20260521141202_add_video_extended_fields_and_playlists/migration.sql new file mode 100644 index 0000000..10d0790 --- /dev/null +++ b/backend/prisma/migrations/20260521141202_add_video_extended_fields_and_playlists/migration.sql @@ -0,0 +1,50 @@ +-- AlterTable +ALTER TABLE "Video" ADD COLUMN "ageRestricted" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "containsPaidPromotion" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "defaultAudioLanguage" TEXT, +ADD COLUMN "defaultLanguage" TEXT, +ADD COLUMN "embeddable" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "gameTitle" TEXT, +ADD COLUMN "license" TEXT NOT NULL DEFAULT 'youtube', +ADD COLUMN "madeForKids" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "recordingDate" TIMESTAMP(3), +ADD COLUMN "selfDeclaredMadeForKids" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "Playlist" ( + "id" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "youtubePlaylistId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "itemCount" INTEGER NOT NULL DEFAULT 0, + "privacyStatus" TEXT NOT NULL DEFAULT 'public', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Playlist_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VideoPlaylist" ( + "videoId" TEXT NOT NULL, + "playlistId" TEXT NOT NULL, + "position" INTEGER, + + CONSTRAINT "VideoPlaylist_pkey" PRIMARY KEY ("videoId","playlistId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Playlist_youtubePlaylistId_key" ON "Playlist"("youtubePlaylistId"); + +-- CreateIndex +CREATE INDEX "Playlist_channelId_idx" ON "Playlist"("channelId"); + +-- AddForeignKey +ALTER TABLE "Playlist" ADD CONSTRAINT "Playlist_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VideoPlaylist" ADD CONSTRAINT "VideoPlaylist_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VideoPlaylist" ADD CONSTRAINT "VideoPlaylist_playlistId_fkey" FOREIGN KEY ("playlistId") REFERENCES "Playlist"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260521143907_add_user_preferences/migration.sql b/backend/prisma/migrations/20260521143907_add_user_preferences/migration.sql new file mode 100644 index 0000000..69f2c12 --- /dev/null +++ b/backend/prisma/migrations/20260521143907_add_user_preferences/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "preferences" JSONB; diff --git a/backend/prisma/migrations/20260525000000_make_youtube_handle_optional/migration.sql b/backend/prisma/migrations/20260525000000_make_youtube_handle_optional/migration.sql new file mode 100644 index 0000000..f7f313e --- /dev/null +++ b/backend/prisma/migrations/20260525000000_make_youtube_handle_optional/migration.sql @@ -0,0 +1,2 @@ +-- Make youtubeHandle optional on Collaborator, consistent with all other platform fields +ALTER TABLE "Collaborator" ALTER COLUMN "youtubeHandle" DROP NOT NULL; diff --git a/backend/prisma/migrations/20260525000001_rename_youtube_handle_to_link/migration.sql b/backend/prisma/migrations/20260525000001_rename_youtube_handle_to_link/migration.sql new file mode 100644 index 0000000..80ec3f4 --- /dev/null +++ b/backend/prisma/migrations/20260525000001_rename_youtube_handle_to_link/migration.sql @@ -0,0 +1,9 @@ +-- Rename youtubeHandle -> youtubeLink for consistency with other platform fields +ALTER TABLE "Collaborator" RENAME COLUMN "youtubeHandle" TO "youtubeLink"; + +-- Convert existing handle values (@handle or handle) to full YouTube URLs +UPDATE "Collaborator" +SET "youtubeLink" = 'https://www.youtube.com/@' || LTRIM("youtubeLink", '@') +WHERE "youtubeLink" IS NOT NULL + AND "youtubeLink" != '' + AND "youtubeLink" NOT LIKE 'https://%'; diff --git a/backend/prisma/migrations/20260525000002_add_block_compact_default/migration.sql b/backend/prisma/migrations/20260525000002_add_block_compact_default/migration.sql new file mode 100644 index 0000000..7d155ce --- /dev/null +++ b/backend/prisma/migrations/20260525000002_add_block_compact_default/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "DescriptionBlock" ADD COLUMN "compact" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/migrations/20260525000003_add_template_default_overrides/migration.sql b/backend/prisma/migrations/20260525000003_add_template_default_overrides/migration.sql new file mode 100644 index 0000000..696e6b0 --- /dev/null +++ b/backend/prisma/migrations/20260525000003_add_template_default_overrides/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Template" ADD COLUMN "defaultOverrides" JSONB NOT NULL DEFAULT '{}'::jsonb; diff --git a/backend/prisma/migrations/20260526000000_consolidate_collaborator_ids/migration.sql b/backend/prisma/migrations/20260526000000_consolidate_collaborator_ids/migration.sql new file mode 100644 index 0000000..2a536df --- /dev/null +++ b/backend/prisma/migrations/20260526000000_consolidate_collaborator_ids/migration.sql @@ -0,0 +1,17 @@ +-- Add collaboratorIds directly to Video +ALTER TABLE "Video" ADD COLUMN "collaboratorIds" JSONB NOT NULL DEFAULT '[]'; + +-- Migrate existing data from VideoConfig.collaboratorIds into Video.collaboratorIds +UPDATE "Video" v +SET "collaboratorIds" = vc."collaboratorIds" +FROM "VideoConfig" vc +WHERE vc."videoId" = v.id + AND vc."collaboratorIds" IS NOT NULL + AND vc."collaboratorIds"::text != '[]' + AND vc."collaboratorIds"::text != 'null'; + +-- Drop VideoCollaborator join table +DROP TABLE "VideoCollaborator"; + +-- Remove collaboratorIds from VideoConfig +ALTER TABLE "VideoConfig" DROP COLUMN "collaboratorIds"; diff --git a/backend/prisma/migrations/20260527000000_add_thumbnail_url/migration.sql b/backend/prisma/migrations/20260527000000_add_thumbnail_url/migration.sql new file mode 100644 index 0000000..40caef8 --- /dev/null +++ b/backend/prisma/migrations/20260527000000_add_thumbnail_url/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Video" ADD COLUMN "thumbnailUrl" TEXT; diff --git a/backend/prisma/migrations/20260602000000_add_youtube_snapshot/migration.sql b/backend/prisma/migrations/20260602000000_add_youtube_snapshot/migration.sql new file mode 100644 index 0000000..2cd1e87 --- /dev/null +++ b/backend/prisma/migrations/20260602000000_add_youtube_snapshot/migration.sql @@ -0,0 +1,2 @@ +-- Add YouTube baseline snapshot to Video for full-field diff +ALTER TABLE "Video" ADD COLUMN "youtubeSnapshot" JSONB; diff --git a/backend/prisma/migrations/20260602000001_quota_log_action_tracking/migration.sql b/backend/prisma/migrations/20260602000001_quota_log_action_tracking/migration.sql new file mode 100644 index 0000000..195d4fd --- /dev/null +++ b/backend/prisma/migrations/20260602000001_quota_log_action_tracking/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "QuotaLog" ADD COLUMN "actionId" TEXT; +ALTER TABLE "QuotaLog" ADD COLUMN "actionType" TEXT; +CREATE INDEX "QuotaLog_actionId_idx" ON "QuotaLog"("actionId"); diff --git a/backend/prisma/migrations/20260605000000_publishing_schedule/migration.sql b/backend/prisma/migrations/20260605000000_publishing_schedule/migration.sql new file mode 100644 index 0000000..6ac2f09 --- /dev/null +++ b/backend/prisma/migrations/20260605000000_publishing_schedule/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Team" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'UTC'; +ALTER TABLE "Team" ADD COLUMN "publishingSchedule" JSONB; diff --git a/backend/prisma/migrations/20260605000001_show_canva_link/migration.sql b/backend/prisma/migrations/20260605000001_show_canva_link/migration.sql new file mode 100644 index 0000000..3ef376b --- /dev/null +++ b/backend/prisma/migrations/20260605000001_show_canva_link/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Team" ADD COLUMN "showCanvaLink" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/migrations/20260605000002_disabled_lint_rules/migration.sql b/backend/prisma/migrations/20260605000002_disabled_lint_rules/migration.sql new file mode 100644 index 0000000..7c7501c --- /dev/null +++ b/backend/prisma/migrations/20260605000002_disabled_lint_rules/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Team" ADD COLUMN "disabledLintRules" TEXT[] NOT NULL DEFAULT '{}'; diff --git a/backend/prisma/migrations/20260606000000_video_youtube_deleted_at/migration.sql b/backend/prisma/migrations/20260606000000_video_youtube_deleted_at/migration.sql new file mode 100644 index 0000000..e9f643a --- /dev/null +++ b/backend/prisma/migrations/20260606000000_video_youtube_deleted_at/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Video" ADD COLUMN "youtubeDeletedAt" TIMESTAMP(3); diff --git a/backend/prisma/migrations/20260606000001_team_show_deleted_videos/migration.sql b/backend/prisma/migrations/20260606000001_team_show_deleted_videos/migration.sql new file mode 100644 index 0000000..d425fba --- /dev/null +++ b/backend/prisma/migrations/20260606000001_team_show_deleted_videos/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Team" ADD COLUMN "showDeletedVideos" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/migrations/20260608000001_capture_schema_drift/migration.sql b/backend/prisma/migrations/20260608000001_capture_schema_drift/migration.sql new file mode 100644 index 0000000..489dadc --- /dev/null +++ b/backend/prisma/migrations/20260608000001_capture_schema_drift/migration.sql @@ -0,0 +1,15 @@ +-- Capture schema drift: columns added directly to DB without migration files + +ALTER TABLE "Team" ADD COLUMN IF NOT EXISTS "dateFormat" TEXT; + +ALTER TABLE "Channel" ADD COLUMN IF NOT EXISTS "supplementalVideoIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; + +ALTER TABLE "DescriptionBlock" ADD COLUMN IF NOT EXISTS "condition" JSONB; + +ALTER TABLE "Template" ADD COLUMN IF NOT EXISTS "videoFields" JSONB; + +ALTER TABLE "Collaborator" ADD COLUMN IF NOT EXISTS "instagramLink" TEXT; +ALTER TABLE "Collaborator" ADD COLUMN IF NOT EXISTS "tiktokLink" TEXT; +ALTER TABLE "Collaborator" ADD COLUMN IF NOT EXISTS "twitterLink" TEXT; +ALTER TABLE "Collaborator" ADD COLUMN IF NOT EXISTS "blueskyLink" TEXT; +ALTER TABLE "Collaborator" ADD COLUMN IF NOT EXISTS "discordHandle" TEXT; diff --git a/backend/prisma/migrations/20260608000002_fix_supplemental_video_ids_default/migration.sql b/backend/prisma/migrations/20260608000002_fix_supplemental_video_ids_default/migration.sql new file mode 100644 index 0000000..4f4c8e8 --- /dev/null +++ b/backend/prisma/migrations/20260608000002_fix_supplemental_video_ids_default/migration.sql @@ -0,0 +1,2 @@ +-- Fix: remove the default from supplementalVideoIds — column was added without a default in DB +ALTER TABLE "Channel" ALTER COLUMN "supplementalVideoIds" DROP DEFAULT; diff --git a/backend/prisma/migrations/20260608173234_add_saved_view_tab_fields/migration.sql b/backend/prisma/migrations/20260608173234_add_saved_view_tab_fields/migration.sql new file mode 100644 index 0000000..44d9aad --- /dev/null +++ b/backend/prisma/migrations/20260608173234_add_saved_view_tab_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "SavedView" ADD COLUMN "pinnedAsTab" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "tabOrder" INTEGER; diff --git a/backend/prisma/migrations/20260629000000_add_conflict_detection_settings/migration.sql b/backend/prisma/migrations/20260629000000_add_conflict_detection_settings/migration.sql new file mode 100644 index 0000000..5215e5a --- /dev/null +++ b/backend/prisma/migrations/20260629000000_add_conflict_detection_settings/migration.sql @@ -0,0 +1,8 @@ +ALTER TABLE "Team" + ADD COLUMN "conflictDetectionEnabled" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN "conflictDetectionBatchSize" INTEGER NOT NULL DEFAULT 50, + ADD COLUMN "conflictDetectionMinAgeDays" INTEGER NOT NULL DEFAULT 7; + +ALTER TABLE "Video" + ADD COLUMN "pendingRemoteSnapshot" JSONB, + ADD COLUMN "pendingRemoteDescription" TEXT; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..cdf0b52 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,492 @@ +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "linux-musl-openssl-3.0.x"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ─── USERS ──────────────────────────────────────────────── + +model User { + id String @id @default(cuid()) + email String @unique + name String? + googleId String @unique + isAppAdmin Boolean @default(false) + preferences Json? + teams TeamMember[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// ─── TEAMS ──────────────────────────────────────────────── + +model Team { + id String @id @default(cuid()) + name String + slug String @unique + dateFormat String? + timezone String @default("UTC") + publishingSchedule Json? + showCanvaLink Boolean @default(false) + disabledLintRules String[] @default([]) + showDeletedVideos Boolean @default(false) + conflictDetectionEnabled Boolean @default(false) + conflictDetectionBatchSize Int @default(50) + conflictDetectionMinAgeDays Int @default(7) + members TeamMember[] + channels Channel[] + blocks DescriptionBlock[] + templates Template[] + collaborators Collaborator[] + bulkJobs BulkJob[] + savedViews SavedView[] + importJobs ImportJob[] + exportJobs ExportJob[] + campaigns Campaign[] + variables TeamVariable[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model TeamVariable { + id String @id @default(cuid()) + teamId String + team Team @relation(fields: [teamId], references: [id]) + name String + value String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([teamId, name]) + @@index([teamId]) +} + +model TeamMember { + userId String + teamId String + role TeamRole @default(EDITOR) + user User @relation(fields: [userId], references: [id]) + team Team @relation(fields: [teamId], references: [id]) + createdAt DateTime @default(now()) + + @@id([userId, teamId]) + @@index([teamId]) +} + +enum TeamRole { + OWNER + ADMIN + EDITOR + REVIEWER + READONLY +} + +// ─── CHANNELS ───────────────────────────────────────────── + +model Channel { + id String @id @default(cuid()) + teamId String + team Team @relation(fields: [teamId], references: [id]) + youtubeChannelId String @unique + name String + uploadsPlaylistId String? + supplementalVideoIds String[] + youtubeAccessToken String? + youtubeRefreshToken String? + youtubeTokenExpiry DateTime? + connectedBy String + videos Video[] + playlists Playlist[] + quotaLogs QuotaLog[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([teamId]) +} + +// ─── VIDEOS ─────────────────────────────────────────────── + +model Video { + id String @id @default(cuid()) + youtubeVideoId String @unique + channelId String + channel Channel @relation(fields: [channelId], references: [id]) + title String + youtubeDescription String? + renderedDescription String? + thumbnailUrl String? + tags String[] + categoryId String? + privacyStatus PrivacyStatus @default(PRIVATE) + publishedAt DateTime? + scheduledAt DateTime? + templateId String? + // Extended metadata + madeForKids Boolean @default(false) + selfDeclaredMadeForKids Boolean @default(false) + containsPaidPromotion Boolean @default(false) + ageRestricted Boolean @default(false) + embeddable Boolean @default(true) + license String @default("youtube") + defaultLanguage String? + defaultAudioLanguage String? + recordingDate DateTime? + gameTitle String? + collaboratorIds Json @default("[]") + youtubeSnapshot Json? + lintStatus LintStatus @default(OK) + lastSyncedAt DateTime? + lastSyncedHash String? + remoteConflict Boolean @default(false) + pendingRemoteSnapshot Json? + pendingRemoteDescription String? + youtubeDeletedAt DateTime? + template Template? @relation(fields: [templateId], references: [id]) + config VideoConfig? + playlists VideoPlaylist[] + lintResults LintResult[] + bulkJobItems BulkJobItem[] + quotaLogs QuotaLog[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([channelId]) +} + +enum PrivacyStatus { + PUBLIC + PRIVATE + UNLISTED +} + +enum LintStatus { + OK + WARNING + ERROR +} + +// ─── VIDEO CONFIG ───────────────────────────────────────── + +model VideoConfig { + id String @id @default(cuid()) + videoId String @unique + video Video @relation(fields: [videoId], references: [id]) + templateId String? + blockOrder Json + blockOverrides Json + variableValues Json + version Int @default(1) + renderHash String? + renderedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// ─── DESCRIPTION BLOCKS ─────────────────────────────────── + +model DescriptionBlock { + id String @id @default(cuid()) + teamId String + team Team @relation(fields: [teamId], references: [id]) + name String + type BlockType + content String + language String @default("de") + campaignId String? + campaign Campaign? @relation(fields: [campaignId], references: [id]) + version Int @default(1) + active Boolean @default(true) + compact Boolean @default(false) + tags String[] + variableDefinitions Json @default("[]") + condition Json? + versions BlockVersion[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([teamId]) +} + +enum BlockType { + STATIC + VARIABLE + CONDITIONAL + REPEATABLE + GLOBAL + CAMPAIGN + COLLABORATOR +} + +model BlockVersion { + id String @id @default(cuid()) + blockId String + block DescriptionBlock @relation(fields: [blockId], references: [id]) + version Int + contentSnapshot Json + createdAt DateTime @default(now()) + createdBy String? +} + +// ─── TEMPLATES ──────────────────────────────────────────── + +model Template { + id String @id @default(cuid()) + teamId String + team Team @relation(fields: [teamId], references: [id]) + name String + description String? + defaultBlocks Json + defaultOverrides Json @default("{}") + rules Json + variables Json + videoFields Json? + version Int @default(1) + active Boolean @default(true) + videos Video[] + versions TemplateVersion[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([teamId]) +} + +model TemplateVersion { + id String @id @default(cuid()) + templateId String + template Template @relation(fields: [templateId], references: [id]) + version Int + snapshot Json + createdAt DateTime @default(now()) +} + +// ─── COLLABORATORS ──────────────────────────────────────── + +model Collaborator { + id String @id @default(cuid()) + teamId String + team Team @relation(fields: [teamId], references: [id]) + name String + youtubeLink String? + twitchLink String? + instagramLink String? + tiktokLink String? + twitterLink String? + blueskyLink String? + discordHandle String? + aliases String[] + active Boolean @default(true) + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([teamId]) +} + +// ─── SAVED VIEWS ────────────────────────────────────────── + +model SavedView { + id String @id @default(cuid()) + teamId String + team Team @relation(fields: [teamId], references: [id]) + name String + description String? + isGlobal Boolean @default(false) + ownerId String? + queryJson Json + columnsJson Json + sortJson Json? + pinnedAsTab Boolean @default(false) + tabOrder Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([teamId]) +} + +// ─── LINT RESULTS ───────────────────────────────────────── + +model LintResult { + id String @id @default(cuid()) + videoId String + video Video @relation(fields: [videoId], references: [id]) + ruleCode String + severity LintSeverity + targetField String? + message String + fixSuggestion String? + resolvedAt DateTime? + createdAt DateTime @default(now()) + + @@index([videoId, severity]) + @@index([ruleCode]) +} + +enum LintSeverity { + INFO + WARNING + ERROR +} + +// ─── BULK JOBS ──────────────────────────────────────────── + +model BulkJob { + id String @id @default(cuid()) + teamId String + team Team @relation(fields: [teamId], references: [id]) + type String + initiatedBy String + filterSnapshot Json + targetIds String[] + status BulkJobStatus @default(PENDING) + dryRunResult Json? + rollbackData Json? + totalCount Int + successCount Int @default(0) + errorCount Int @default(0) + items BulkJobItem[] + createdAt DateTime @default(now()) + completedAt DateTime? + + @@index([teamId]) +} + +enum BulkJobStatus { + PENDING + DRY_RUN + CONFIRMED + RUNNING + DONE + FAILED + ROLLED_BACK +} + +model BulkJobItem { + id String @id @default(cuid()) + bulkJobId String + bulkJob BulkJob @relation(fields: [bulkJobId], references: [id]) + videoId String + video Video @relation(fields: [videoId], references: [id]) + beforeSnapshot Json? + afterSnapshot Json? + status String @default("pending") + errorMessage String? + createdAt DateTime @default(now()) +} + +// ─── CAMPAIGNS ──────────────────────────────────────────── + +model Campaign { + id String @id @default(cuid()) + teamId String + team Team @relation(fields: [teamId], references: [id]) + name String + startAt DateTime + endAt DateTime? + status String @default("active") + notes String? + blocks DescriptionBlock[] + + @@index([teamId]) +} + +// ─── PLAYLISTS ──────────────────────────────────────────── + +model Playlist { + id String @id @default(cuid()) + channelId String + channel Channel @relation(fields: [channelId], references: [id]) + youtubePlaylistId String @unique + title String + description String? + itemCount Int @default(0) + privacyStatus String @default("public") + videos VideoPlaylist[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([channelId]) +} + +model VideoPlaylist { + videoId String + playlistId String + position Int? + video Video @relation(fields: [videoId], references: [id]) + playlist Playlist @relation(fields: [playlistId], references: [id]) + + @@id([videoId, playlistId]) +} + +// ─── IMPORT / EXPORT JOBS ───────────────────────────────── + +model ImportJob { + id String @id @default(cuid()) + teamId String + team Team @relation(fields: [teamId], references: [id]) + type String + sourceName String + mappingJson Json? + validationReport Json? + commitStatus String @default("pending") + createdBy String + createdAt DateTime @default(now()) + committedAt DateTime? + + @@index([teamId]) +} + +model ExportJob { + id String @id @default(cuid()) + teamId String + team Team @relation(fields: [teamId], references: [id]) + type String + scopeJson Json + fileReference String? + createdBy String + createdAt DateTime @default(now()) + + @@index([teamId]) +} + +// ─── YOUTUBE QUOTA LOG ──────────────────────────────────── + +model QuotaLog { + id String @id @default(cuid()) + datePt DateTime + units Int + operation String + entityId String? + channelId String? + channel Channel? @relation(fields: [channelId], references: [id]) + videoId String? + video Video? @relation(fields: [videoId], references: [id]) + bulkJobId String? + actionId String? + actionType String? + createdAt DateTime @default(now()) + + @@index([datePt]) + @@index([channelId]) + @@index([actionId]) +} + +// ─── AUDIT LOG ──────────────────────────────────────────── + +model AuditLog { + id String @id @default(cuid()) + actorId String + entityType String + entityId String + action String + beforeJson Json? + afterJson Json? + requestId String? + createdAt DateTime @default(now()) + + @@index([entityType, entityId]) + @@index([createdAt]) +} diff --git a/backend/scripts/import-missing-videos.mjs b/backend/scripts/import-missing-videos.mjs new file mode 100644 index 0000000..f4022ce --- /dev/null +++ b/backend/scripts/import-missing-videos.mjs @@ -0,0 +1,112 @@ +import { PrismaClient } from '@prisma/client'; +import { createDecipheriv, scryptSync } from 'crypto'; +import { google } from 'googleapis'; + +const MISSING_IDS = [ + '4dcg46RQqnM', + '9xsmLBY20wA', + 'DmmQ7HPxBRg', + 'G58lGGaMOOY', + 'I5u-8xk6BHM', + 'OdHheItBlyQ', + 'VtfmpQXPheA', + 'hIgaxCUX9ts', + 'nHWtSlE-kgE', + 'ye3b5J7zX0M', + 'zW7yrpUs34Q', +]; + +const CHANNEL_ID = 'cmpfdm4el008nn3uoemascs0z'; + +const PRIVACY_MAP = { public: 'PUBLIC', private: 'PRIVATE', unlisted: 'UNLISTED' }; + +function decrypt(text) { + const key = scryptSync('7017a681f5b32c3799d1bbf58ce58064', 'studioflow-salt', 32); + const [ivHex, encHex] = text.split(':'); + const decipher = createDecipheriv('aes-256-cbc', key, Buffer.from(ivHex, 'hex')); + return Buffer.concat([decipher.update(Buffer.from(encHex, 'hex')), decipher.final()]).toString('utf8'); +} + +const prisma = new PrismaClient(); + +const channel = await prisma.channel.findUniqueOrThrow({ where: { id: CHANNEL_ID } }); + +// Persist supplemental IDs on the channel +const existing = new Set(channel.supplementalVideoIds ?? []); +for (const id of MISSING_IDS) existing.add(id); +await prisma.channel.update({ + where: { id: CHANNEL_ID }, + data: { supplementalVideoIds: [...existing] }, +}); +console.log(`Saved ${existing.size} supplemental video IDs on channel`); + +// Set up YouTube client and refresh access token +const oauth2 = new google.auth.OAuth2( + '496320251583-rpbr13i0rh1112cdvv9ui5s373735qqj.apps.googleusercontent.com', + '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', +); +oauth2.setCredentials({ + access_token: decrypt(channel.youtubeAccessToken), + refresh_token: channel.youtubeRefreshToken ? decrypt(channel.youtubeRefreshToken) : undefined, +}); + +// Force token refresh so we always have a valid access token +const { credentials } = await oauth2.refreshAccessToken(); +oauth2.setCredentials(credentials); +console.log('Access token refreshed'); + +const yt = google.youtube({ version: 'v3', auth: oauth2 }); + +// Fetch metadata for all missing IDs +const res = await yt.videos.list({ + part: ['snippet', 'status'], + id: MISSING_IDS, + maxResults: 50, +}); +const items = res.data.items ?? []; +console.log(`YouTube returned ${items.length} of ${MISSING_IDS.length} requested IDs`); + +const notFound = MISSING_IDS.filter((id) => !items.find((i) => i.id === id)); +if (notFound.length > 0) console.warn(`Not returned by YouTube API: ${notFound.join(', ')}`); + +let created = 0; +let updated = 0; + +for (const item of items) { + const ytId = item.id; + if (!ytId) continue; + + const snippet = item.snippet; + const status = item.status; + + const ytFields = { + title: snippet?.title ?? '(Untitled)', + youtubeDescription: snippet?.description ?? undefined, + tags: snippet?.tags ?? [], + categoryId: snippet?.categoryId ?? undefined, + publishedAt: snippet?.publishedAt ? new Date(snippet.publishedAt) : undefined, + privacyStatus: PRIVACY_MAP[status?.privacyStatus ?? ''] ?? 'PRIVATE', + defaultLanguage: snippet?.defaultLanguage ?? undefined, + defaultAudioLanguage: snippet?.defaultAudioLanguage ?? undefined, + selfDeclaredMadeForKids: status?.selfDeclaredMadeForKids ?? undefined, + embeddable: status?.embeddable ?? undefined, + license: status?.license ?? undefined, + }; + + const existing = await prisma.video.findUnique({ where: { youtubeVideoId: ytId } }); + if (!existing) { + await prisma.video.create({ data: { youtubeVideoId: ytId, channelId: CHANNEL_ID, ...ytFields } }); + console.log(` CREATED: ${ytId} — ${ytFields.title}`); + created++; + } else { + await prisma.video.update({ + where: { youtubeVideoId: ytId }, + data: { channelId: CHANNEL_ID, ...ytFields }, + }); + console.log(` UPDATED: ${ytId} — ${ytFields.title}`); + updated++; + } +} + +console.log(`\nDone: ${created} created, ${updated} updated, ${notFound.length} not found on YouTube`); +await prisma.$disconnect(); diff --git a/backend/scripts/list-playlist.mjs b/backend/scripts/list-playlist.mjs new file mode 100644 index 0000000..0f956c1 --- /dev/null +++ b/backend/scripts/list-playlist.mjs @@ -0,0 +1,44 @@ +import { PrismaClient } from '@prisma/client'; +import { createDecipheriv, scryptSync } from 'crypto'; +import { google } from 'googleapis'; +import { writeFileSync } from 'fs'; + +const prisma = new PrismaClient(); + +function decrypt(text) { + const key = scryptSync('7017a681f5b32c3799d1bbf58ce58064', 'studioflow-salt', 32); + const [ivHex, encHex] = text.split(':'); + const decipher = createDecipheriv('aes-256-cbc', key, Buffer.from(ivHex, 'hex')); + return Buffer.concat([decipher.update(Buffer.from(encHex, 'hex')), decipher.final()]).toString('utf8'); +} + +const channel = await prisma.channel.findFirst({ where: { name: 'My Channel' } }); +const oauth2 = new google.auth.OAuth2( + '496320251583-rpbr13i0rh1112cdvv9ui5s373735qqj.apps.googleusercontent.com', + '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', +); +oauth2.setCredentials({ + access_token: decrypt(channel.youtubeAccessToken), + refresh_token: channel.youtubeRefreshToken ? decrypt(channel.youtubeRefreshToken) : undefined, +}); + +const yt = google.youtube({ version: 'v3', auth: oauth2 }); + +const pages = []; +let pageToken; +do { + const res = await yt.playlistItems.list({ + part: ['contentDetails', 'snippet', 'status'], + playlistId: channel.uploadsPlaylistId, + maxResults: 50, + ...(pageToken ? { pageToken } : {}), + }); + pages.push(res.data); + pageToken = res.data.nextPageToken; +} while (pageToken); + +const outPath = new URL('./playlist-raw.json', import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1'); +writeFileSync(outPath, JSON.stringify(pages, null, 2)); +console.log(`Wrote ${pages.length} pages to ${outPath}`); + +await prisma.$disconnect(); diff --git a/backend/scripts/playlist-raw.json b/backend/scripts/playlist-raw.json new file mode 100644 index 0000000..7caa33b --- /dev/null +++ b/backend/scripts/playlist-raw.json @@ -0,0 +1,21938 @@ +[ + { + "kind": "youtube#playlistItemListResponse", + "etag": "5_XT8QaY0ZIcSNkZU909iK-93-A", + "nextPageToken": "EAAaBlBUOkNESQ", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "toaqYKKf40p24lWo14xqs4ba0yI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLi1NZ0IwWDBYQXlF", + "snippet": { + "publishedAt": "2026-05-20T20:25:54Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Something is different... | !dc !commands !meow", + "description": "Stream on April 1, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/-MgB0X0XAyE/default.jpg?sqp=CJSIwdAG&rs=AOn4CLAlR6vlH3Og345BZTwhYJJdd-PQWw", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/-MgB0X0XAyE/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBfWGWYPUz0Sgq2q_ry9ThtaoORJg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/-MgB0X0XAyE/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCLwvRcVkXObbbFH-zsedd-nwKczA", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/-MgB0X0XAyE/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLAP_-J8fWruIvkj0z7iwYSQ_ZO26w", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/-MgB0X0XAyE/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDIqpVayYG4LNqVElCvDoxY1KCfpw", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 0, + "resourceId": { + "kind": "youtube#video", + "videoId": "-MgB0X0XAyE" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "-MgB0X0XAyE", + "videoPublishedAt": "2026-05-20T20:25:54Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "YHQNivkn4wYsjMhAcRpWCBmR4w4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlZUWW9Ma19heWhV", + "snippet": { + "publishedAt": "2026-05-20T20:25:51Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "REPO with Crowd Control! - You control what happens to us | !dc !commands !meow", + "description": "Stream on March 31, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/VTYoLk_ayhU/default.jpg?sqp=CJSIwdAG&rs=AOn4CLD6NgNmEagRjubOwKsNnASQmpw34Q", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/VTYoLk_ayhU/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLD_OP_b4aTLV7SQWnt3eK1K_dj_8g", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/VTYoLk_ayhU/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLD6f2cLs8o7SatCAr9DmYosQbBHFQ", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/VTYoLk_ayhU/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLAufXkHuZZKrlj2F437SXWrI8Y53w", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/VTYoLk_ayhU/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCL3UAEUt1OQTUOP1TrZBkPFBWPoQ", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 1, + "resourceId": { + "kind": "youtube#video", + "videoId": "VTYoLk_ayhU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "VTYoLk_ayhU", + "videoPublishedAt": "2026-05-20T20:25:51Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "_iWA5dFkl3Njw7zBzeFMdXG_Fz0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjZIWkJ4ckp5MU5N", + "snippet": { + "publishedAt": "2026-05-20T20:25:34Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Devil and Scar Road Inc. at work! | !dc !commands !meow", + "description": "Stream on March 24, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/6HZBxrJy1NM/default.jpg?sqp=CJSIwdAG&rs=AOn4CLDdZrzDWqMA0M0tMgSxfNZyUmDkmA", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/6HZBxrJy1NM/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDJt8Y45rGYlARJeoo8dzhM5KoSrw", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/6HZBxrJy1NM/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCOuLUlGPZmq-Oa_Y8qwK3J--O2Ew", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/6HZBxrJy1NM/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBwHIdj6URRvG2Q2S8UsBzESfD8XQ", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/6HZBxrJy1NM/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCN4qnBLsKYQeSAqNfKj7dCnm-iRg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 2, + "resourceId": { + "kind": "youtube#video", + "videoId": "6HZBxrJy1NM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "6HZBxrJy1NM", + "videoPublishedAt": "2026-05-20T20:25:34Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "GJQtGixzJ-tIU2Fdbhtv034RzVY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLndzV3BEdUx6dlhV", + "snippet": { + "publishedAt": "2026-05-20T20:25:19Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Taking care of humanity? or something else? there have been updates!", + "description": "Stream on March 22, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/wsWpDuLzvXU/default.jpg?sqp=CJSIwdAG&rs=AOn4CLBEpVMslC4qFlGhMq1dW5TE6KHYmQ", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/wsWpDuLzvXU/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCgWvTb4rBAxLgbHf7k2IFivhBk5w", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/wsWpDuLzvXU/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBQRhQldr1eiDZ6FOu7lSq6oRjUlA", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/wsWpDuLzvXU/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLAgcAIif8KM76mhmLebSYOberEQgA", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/wsWpDuLzvXU/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDpCJGGSnNhlxToM0zxMYbT4sXPyQ", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 3, + "resourceId": { + "kind": "youtube#video", + "videoId": "wsWpDuLzvXU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "wsWpDuLzvXU", + "videoPublishedAt": "2026-05-20T20:25:19Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "cbXubfOtmm4PVRG761TRe4BM3fE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmFSSy1jX1NzcEtz", + "snippet": { + "publishedAt": "2026-05-20T20:15:48Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Ram and Devil Farming Project | !dc !commands !meow", + "description": "Stream on March 27, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/aRK-c_SspKs/default.jpg?sqp=CJSIwdAG&rs=AOn4CLBKxXXX3oWiLJxHM1ye6ViiGbuBNg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/aRK-c_SspKs/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCDL0X4SeAXVHrv2CLaFxdTkipmKw", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/aRK-c_SspKs/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLB9FkZbs8E_l_gWs0kbZGd899vnRg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/aRK-c_SspKs/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCNx6hxq9Wnn4I_tixWyuNloQfLKw", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/aRK-c_SspKs/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBb1-WtKT2mBqRVGo7Lm9AznpwXuA", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 4, + "resourceId": { + "kind": "youtube#video", + "videoId": "aRK-c_SspKs" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "aRK-c_SspKs", + "videoPublishedAt": "2026-05-20T20:15:48Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "S8UHvyxLUWl6jRIeqG5f8RjPfwU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjdOVUU4c0thcENv", + "snippet": { + "publishedAt": "2026-05-20T20:09:51Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "2026 03 20 20 00 09", + "description": "", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/7NUE8sKapCo/default.jpg?sqp=CJSIwdAG&rs=AOn4CLBsvJ-szE0vn3BZ30OAm8xLCEHKhQ", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/7NUE8sKapCo/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLA08Er8R9LFgdMcEePazJ5kl4Qkxw", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/7NUE8sKapCo/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDdCpiMpAyYzjpw4lQ0X5SWqlSrew", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/7NUE8sKapCo/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCptMqiwVGAvnsnpcoaSHgl2qALgg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/7NUE8sKapCo/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDX9X1XcnegNqnli2Z2t2t61sTLfg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 5, + "resourceId": { + "kind": "youtube#video", + "videoId": "7NUE8sKapCo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "7NUE8sKapCo", + "videoPublishedAt": "2026-05-20T20:09:51Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "JSPuJL78Axw8utchVKjNwuP0ydI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnFYN3ZhTURoNnhN", + "snippet": { + "publishedAt": "2026-05-20T20:09:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "2026 03 03 20 01 21", + "description": "", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/qX7vaMDh6xM/default.jpg?sqp=CJSIwdAG&rs=AOn4CLDL6is6v69fn4bWk3KY1fmcotaNIA", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/qX7vaMDh6xM/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLC8uiZPzIild6-8-Wih3OUju7ceWA", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/qX7vaMDh6xM/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBXQZi4_wflo7n5X2sWMCVSeJ3atw", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/qX7vaMDh6xM/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLC5mtU0Lp-zl8vkjLwyuz66XhxA-g", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/qX7vaMDh6xM/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLAY8wjE1M_gooyF7ESP7Mu3jabbOg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 6, + "resourceId": { + "kind": "youtube#video", + "videoId": "qX7vaMDh6xM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "qX7vaMDh6xM", + "videoPublishedAt": "2026-05-20T20:09:00Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "ee6__rkNaU4zBQncRIbuTCG2D3w", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlZSblhRdW9nVHhJ", + "snippet": { + "publishedAt": "2026-05-14T14:22:24Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "15.03. Roads! A lot of them!", + "description": "Stream on March 15, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/VRnXQuogTxI/default.jpg?sqp=CJSIwdAG&rs=AOn4CLDg9VTgde0QG4hNJiEsGkmpKEsHzA", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/VRnXQuogTxI/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDf_lF7oi9E0TgwPZI41mvUHU_hng", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/VRnXQuogTxI/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBjX-2l2SSU9dgijiwcbW6Hhp7cMg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/VRnXQuogTxI/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCXr5Kiyk0uBi0VOpWnUgTG8x_y8A", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/VRnXQuogTxI/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLC6tPM-2GieEsYwieutNavhAVyOBg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 7, + "resourceId": { + "kind": "youtube#video", + "videoId": "VRnXQuogTxI" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "VRnXQuogTxI", + "videoPublishedAt": "2026-05-14T14:22:24Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "D8eTEUoDaaRhdTwCnOH2bUD9-jA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkFyWC1ocVo2X3Nv", + "snippet": { + "publishedAt": "2026-05-14T14:14:45Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "17.03. Something is about to happen. What? I have no clue myself | !dc !commands !meow", + "description": "Stream on March 17, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/ArX-hqZ6_so/default.jpg?sqp=CJSIwdAG&rs=AOn4CLCJ0Vhv5CKI2uHF7lf8mORvlZ1YoA", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/ArX-hqZ6_so/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLAnapWY2UwZIgDxbsoMVACJc5pcWw", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/ArX-hqZ6_so/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLADcv2qCxOydAORFEnpOpj82wWqLw", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/ArX-hqZ6_so/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLC_x3Uk3BSXw3H7ah1g-LXwpByr4Q", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/ArX-hqZ6_so/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLAYn7Kl9Hmj9_APwdelGazywlJbJA", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 8, + "resourceId": { + "kind": "youtube#video", + "videoId": "ArX-hqZ6_so" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "ArX-hqZ6_so", + "videoPublishedAt": "2026-05-14T14:14:45Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "1rApmSw4uvrKZX8QYXidd2R1NTM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmp2VUV0bkRJMVNB", + "snippet": { + "publishedAt": "2026-05-05T09:41:51Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "14.03. | Just gonna chill today", + "description": "Stream on March 14, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/jvUEtnDI1SA/default.jpg?sqp=CJSIwdAG&rs=AOn4CLA9Kn9q81xb8AjNpZjq1RxtI4FCmQ", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/jvUEtnDI1SA/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLD_mZ_2c0Bi6bgN1iAX-y9Ykebn9Q", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/jvUEtnDI1SA/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLAaRxUziCL5cujTkBXhYd-i_YXxvw", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/jvUEtnDI1SA/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCdUE9-FXreAbuR2AE7PuFUfKhK0g", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/jvUEtnDI1SA/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLAIfsrPpdf7scYfsWHNvlGfnUSFLw", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 9, + "resourceId": { + "kind": "youtube#video", + "videoId": "jvUEtnDI1SA" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "jvUEtnDI1SA", + "videoPublishedAt": "2026-05-05T09:41:51Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "3CQg8mwwH5tEl-4dDmATMTPTjls", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmZWalVOeDhyaWs0", + "snippet": { + "publishedAt": "2026-04-30T16:06:19Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "05.03. Invading the little server of @flowaphenyx017 and building a hut somewhere", + "description": "Broadcasted live on Twitch -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/fVjUNx8rik4/default.jpg?sqp=CJSIwdAG&rs=AOn4CLDLabe3sf8YW0ZxmC6Ac3N6VxGo8g", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/fVjUNx8rik4/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBH18pWjSIZWacsXmlYSx7jlgYb9Q", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/fVjUNx8rik4/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCRHBn3A3deEi9aXon-epLghwMnEg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/fVjUNx8rik4/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCqpi7ZIPB261cGejpNk7Q2J_IM8A", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/fVjUNx8rik4/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLANPuDE3gDdokwJkq0tVX0SOBelyQ", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 10, + "resourceId": { + "kind": "youtube#video", + "videoId": "fVjUNx8rik4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "fVjUNx8rik4", + "videoPublishedAt": "2026-04-30T16:06:19Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "BGuG1xSCr6iK5uYHPHpyQqnVDO8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmdKLXc1Z2xlckJn", + "snippet": { + "publishedAt": "2026-04-30T08:03:14Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "10.03. | Building Roads... Or Parking spots... or something", + "description": "Stream on March 10, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/gJ-w5glerBg/default.jpg?sqp=CJSIwdAG&rs=AOn4CLBBNsqBbq_yChNp_VkQKSj3UO6g9w", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/gJ-w5glerBg/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDQZccX4vLtOMbp1fhLLtYtNiZ-cw", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/gJ-w5glerBg/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDwEPn49jigjcKtAz1ZdoA-EKvE4w", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/gJ-w5glerBg/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBzxckTtUleDBvvenTM0qOInLE95Q", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/gJ-w5glerBg/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLAmYcyTKj0J_EQn_t4jrnR4KQgDAA", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 11, + "resourceId": { + "kind": "youtube#video", + "videoId": "gJ-w5glerBg" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "gJ-w5glerBg", + "videoPublishedAt": "2026-04-30T08:03:14Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "rqzXZIT6M_7EF8cyTvD8Sj3L7sU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjdaR05lRGI4a0cw", + "snippet": { + "publishedAt": "2026-04-23T16:19:43Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Building some roads again with @scarofman", + "description": "Stream on March 1, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/7ZGNeDb8kG0/default.jpg?sqp=CJSIwdAG&rs=AOn4CLCRWDfKn93N900VVdl8whlpHrMn8Q", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/7ZGNeDb8kG0/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLAdLcswZXRP-wWeeM28JNBnVBUVag", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/7ZGNeDb8kG0/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLD1N9CHTTK6q1J1ZVUOdRb_Xt039w", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/7ZGNeDb8kG0/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDJFCVCJaSMzZxiNJB95HBqxMJ01w", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/7ZGNeDb8kG0/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCkNrlTtsWF57mOTiW5SKOmLG2CtQ", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 12, + "resourceId": { + "kind": "youtube#video", + "videoId": "7ZGNeDb8kG0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "7ZGNeDb8kG0", + "videoPublishedAt": "2026-04-23T16:19:43Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "B93cWgJ75KErvQiz5ZC1j0Z3suw", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmRrZ2F3UDlUTUFV", + "snippet": { + "publishedAt": "2026-04-20T21:58:05Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Chill Minecraft time - come and hang out!", + "description": "Stream on February 28, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/dkgawP9TMAU/default.jpg?sqp=CJSIwdAG&rs=AOn4CLAhjQ4_RLmDZ3pl-93WLjG6kQaQsg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/dkgawP9TMAU/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDrDIwtf_fkmn1D_Aa9NQ0YKgriig", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/dkgawP9TMAU/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDmSmX1eilr7OyAcNmXX3KO2MNp1w", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/dkgawP9TMAU/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCkFFVPzivx5fWbWqvvnoa4eN1SjQ", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/dkgawP9TMAU/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLAYQI5LVEoxDVnCCw2LVqguMUhLVw", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 13, + "resourceId": { + "kind": "youtube#video", + "videoId": "dkgawP9TMAU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "dkgawP9TMAU", + "videoPublishedAt": "2026-04-20T21:58:05Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "z8ao4ozLFfP457MXm_9x9NiX2SU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmR6OERkS25qbmNV", + "snippet": { + "publishedAt": "2026-04-20T21:53:41Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "First look at Ghost Janitor - I blame @annameison for whatever happens today", + "description": "Stream on February 26, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/dz8DdKnjncU/default.jpg?sqp=CJSIwdAG&rs=AOn4CLDDR5P_lTEO6iXOpmeBcbQYZN1X-g", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/dz8DdKnjncU/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCqgQY-ITFKr4QMFLUfZzs_bEwmig", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/dz8DdKnjncU/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLD3X8DLBx0Epn_AK4nTDI1RdMlQQA", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/dz8DdKnjncU/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLA2z_CM3ymusUtUq8hy8RCD2DVcRg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/dz8DdKnjncU/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLAuNfEUcTIyu1jWN3rDrTK54eoYpA", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 14, + "resourceId": { + "kind": "youtube#video", + "videoId": "dz8DdKnjncU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "dz8DdKnjncU", + "videoPublishedAt": "2026-04-20T21:53:41Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "MGxvlj6he4E4G09ugJAo6KigHak", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmY4blo1NEN5bUxj", + "snippet": { + "publishedAt": "2026-04-20T21:51:02Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "3 Affiliate Anniversary time ! Playing some Minecraft and yapping with friends", + "description": "Stream on February 22, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/f8nZ54CymLc/default.jpg?sqp=CJSIwdAG&rs=AOn4CLCt7AQI1zpJCkV4YWL9vkaXVzk0Xw", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/f8nZ54CymLc/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCDFxl8Zd-2Rtevy-YF0bx7phBc6A", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/f8nZ54CymLc/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLC4qun-wPkcyAj-HsRgC9xMYD2TqA", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/f8nZ54CymLc/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBFC1_VfMNOPvs1gn_jdtG6LD3crQ", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/f8nZ54CymLc/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCwgW6mJ53w-fgybvIMB-vAp-cjRQ", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 15, + "resourceId": { + "kind": "youtube#video", + "videoId": "f8nZ54CymLc" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "f8nZ54CymLc", + "videoPublishedAt": "2026-04-20T21:51:02Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "5XV1SgD8SyoFEW4qFMwyVR_kzjE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjVFT3E3cnp4Qldr", + "snippet": { + "publishedAt": "2026-04-20T21:46:36Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "2 Affiliate Anniversary time! Playing some Ready or Not with friends and being bad at it", + "description": "Stream on February 22, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/5EOq7rzxBWk/default.jpg?sqp=CJSIwdAG&rs=AOn4CLAsBtEUEd4McIJkN8w1WMwaXotLUQ", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/5EOq7rzxBWk/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBNW352AJP2BhYfDk6f99vMTiVbdg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/5EOq7rzxBWk/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBF5-cyn1Qr92jj6gymkUqF5MfY9Q", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/5EOq7rzxBWk/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDpSPEfQFqvqmjg-0BzHqpeNoKJYg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/5EOq7rzxBWk/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDD3JAI0Tz9ooKcSknOXOY17YkkDQ", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 16, + "resourceId": { + "kind": "youtube#video", + "videoId": "5EOq7rzxBWk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "5EOq7rzxBWk", + "videoPublishedAt": "2026-04-20T21:46:36Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "UzrvJxeCNj5YMUCF10I13oUCtmc", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkRiZG5yX0h0akNj", + "snippet": { + "publishedAt": "2026-04-20T21:46:25Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "4 Affiliate Anniversary time! Playing REPO with friends and being bad at it", + "description": "Stream on February 22, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/Dbdnr_HtjCc/default.jpg?sqp=CJSIwdAG&rs=AOn4CLBy0P9IZnfx88I0S_S9cKKA1_uYvA", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/Dbdnr_HtjCc/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLAV6HSPb_lL12dxAgT4U81hPN9YLw", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/Dbdnr_HtjCc/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDJJ41HFkPJ-K6gb_oVDva9rdohxw", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/Dbdnr_HtjCc/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLANFS2yVgg0h-6xicWGkfibpim_pA", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/Dbdnr_HtjCc/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDoIRtDHUv2MLnJ7jTmGSYWNoLGOQ", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 17, + "resourceId": { + "kind": "youtube#video", + "videoId": "Dbdnr_HtjCc" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Dbdnr_HtjCc", + "videoPublishedAt": "2026-04-20T21:46:25Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "vNQFDEDIz-SCIp2WV-bWkvTee2I", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLklmWk1PdVJqVmU4", + "snippet": { + "publishedAt": "2026-04-20T21:46:06Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "1 Affiliate Anniversary time! playing Deadlock with friends and being bad at it", + "description": "Stream on February 22, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/IfZMOuRjVe8/default.jpg?sqp=CJSIwdAG&rs=AOn4CLB59yhcnOjyfk_p3X1QWwGeDpv6SA", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/IfZMOuRjVe8/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBiXM-brqrIvNKDX_iVu9DS8NSKEA", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/IfZMOuRjVe8/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCg0uCeAXAkDhNczpqevxz0LuMMog", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/IfZMOuRjVe8/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLAL36pORHgkHklyzzA1LvdR0M3_mw", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/IfZMOuRjVe8/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLAdCWP4B4ERJZR1DTJV4b1RiFjdiQ", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 18, + "resourceId": { + "kind": "youtube#video", + "videoId": "IfZMOuRjVe8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "IfZMOuRjVe8", + "videoPublishedAt": "2026-04-20T21:46:06Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "ep_3q3tuGsawplH-3AYItBtS46s", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkZyeUwtVWhuSWkw", + "snippet": { + "publishedAt": "2026-03-01T17:26:54Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Long time no see! lets go farming with some mechs, right @ramskelly", + "description": "Stream on February 19, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/FryL-UhnIi0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/FryL-UhnIi0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/FryL-UhnIi0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/FryL-UhnIi0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/FryL-UhnIi0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 19, + "resourceId": { + "kind": "youtube#video", + "videoId": "FryL-UhnIi0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "FryL-UhnIi0", + "videoPublishedAt": "2026-03-01T17:26:54Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "-fP51lZmdI_MZG87IOwIAP6jf3s", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnZxbmR1dUpoZjhN", + "snippet": { + "publishedAt": "2026-03-01T05:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "REANIMAL!!! Continuing Where will the story take us? | w/ @annameison", + "description": "Part 2 of our REANIMAL journey.\nWill we finish the story today?\n\nGuest:\nhttps://twitch.tv/annameison\n\nStream on February 17, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/vqnduuJhf8M/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/vqnduuJhf8M/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/vqnduuJhf8M/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/vqnduuJhf8M/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/vqnduuJhf8M/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 20, + "resourceId": { + "kind": "youtube#video", + "videoId": "vqnduuJhf8M" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "vqnduuJhf8M", + "videoPublishedAt": "2026-03-01T05:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "32KNTet9qu-bgZHaWi38xVdhvak", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLl8weklpWVk5Z29j", + "snippet": { + "publishedAt": "2026-02-28T17:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Testing our knowledge in Contexto", + "description": "Stream on February 17, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/_0zIiYY9goc/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/_0zIiYY9goc/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/_0zIiYY9goc/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/_0zIiYY9goc/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/_0zIiYY9goc/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 21, + "resourceId": { + "kind": "youtube#video", + "videoId": "_0zIiYY9goc" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "_0zIiYY9goc", + "videoPublishedAt": "2026-02-28T17:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "RtjGQ62hYtrtKU42kO8n5hGPTm8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkVLcDhfeXNYODRr", + "snippet": { + "publishedAt": "2026-02-28T11:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "REANIMAL?!?! We are here, we are excited! | w/ @annameison", + "description": "Finally its here! Playing REANIMAL with @annameison \n\nGuest:\nhttps://twitch.tv/annameison\n\nStream on February 13, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/EKp8_ysX84k/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/EKp8_ysX84k/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/EKp8_ysX84k/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/EKp8_ysX84k/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/EKp8_ysX84k/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 22, + "resourceId": { + "kind": "youtube#video", + "videoId": "EKp8_ysX84k" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "EKp8_ysX84k", + "videoPublishedAt": "2026-02-28T11:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "kwM_-aOvGONgtNNbmGhf9AxGmwk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmJxVTRWRk9reE1V", + "snippet": { + "publishedAt": "2026-02-28T05:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Words on Stream to bridge some time", + "description": "Stream on February 13, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/bqU4VFOkxMU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/bqU4VFOkxMU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/bqU4VFOkxMU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/bqU4VFOkxMU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/bqU4VFOkxMU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 23, + "resourceId": { + "kind": "youtube#video", + "videoId": "bqU4VFOkxMU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "bqU4VFOkxMU", + "videoPublishedAt": "2026-02-28T05:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "oadtrFm__qjI10hwqrmjaH5lGu0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk8wMlcxOVFaRDdR", + "snippet": { + "publishedAt": "2026-02-27T17:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Fun with Friends! Community-Server stuff with @SpicyNoobles and @Dobzian", + "description": "Cave Building, Mining, etc. \n\nGuests:\nhttps://twitch.tv/SpicyNoobles\nhttps://twitch.tv/Dobzian\n\nStream on February 8, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/O02W19QZD7Q/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/O02W19QZD7Q/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/O02W19QZD7Q/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/O02W19QZD7Q/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/O02W19QZD7Q/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 24, + "resourceId": { + "kind": "youtube#video", + "videoId": "O02W19QZD7Q" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "O02W19QZD7Q", + "videoPublishedAt": "2026-02-27T17:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "v1jSze1lmOQ1dhJXRI5HVBCKcUg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmZLVlJ2RTB1a2hR", + "snippet": { + "publishedAt": "2026-02-27T11:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Just some chill Minecraft after being sick", + "description": "Stream on February 6, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/fKVRvE0ukhQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/fKVRvE0ukhQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/fKVRvE0ukhQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/fKVRvE0ukhQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/fKVRvE0ukhQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 25, + "resourceId": { + "kind": "youtube#video", + "videoId": "fKVRvE0ukhQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "fKVRvE0ukhQ", + "videoPublishedAt": "2026-02-27T11:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "N9YEUkk0uK5RZ1QYrzQRRadi85U", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLl9VMnhyYkFseFh3", + "snippet": { + "publishedAt": "2026-02-27T07:57:14Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Little things on the community server and getting elytra for @Dobzian", + "description": "Playing on the Minecraft Community Server. \nExploring the end to find elytra for Dobzian.\n\nGuest:\nhttps://twitch.tv/Dobzian\n\nStream on February 1, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/_U2xrbAlxXw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/_U2xrbAlxXw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/_U2xrbAlxXw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/_U2xrbAlxXw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/_U2xrbAlxXw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 26, + "resourceId": { + "kind": "youtube#video", + "videoId": "_U2xrbAlxXw" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "_U2xrbAlxXw", + "videoPublishedAt": "2026-02-27T07:57:14Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "CyRKGySyTkkPmfwrG33HVmscFqQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLld4dmYzOTV2OFdr", + "snippet": { + "publishedAt": "2026-02-26T17:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Something escaped. or did we? REPO to decompress after SCP", + "description": "Guest:\nhttps://twitch.tv/captain_pesky\n\nStream on January 27, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Wxvf395v8Wk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Wxvf395v8Wk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Wxvf395v8Wk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Wxvf395v8Wk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Wxvf395v8Wk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 27, + "resourceId": { + "kind": "youtube#video", + "videoId": "Wxvf395v8Wk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Wxvf395v8Wk", + "videoPublishedAt": "2026-02-26T17:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "ISVY8b7mxvoP_5MlaalkbJZ4Kf8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjhLdDAwcDVBWHZj", + "snippet": { + "publishedAt": "2026-02-26T11:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Something escaped... First try of SCP Containment Breach", + "description": "First touch on SCP Containment Breach Multiplayer with Captain_Pesky\n\nGuest:\nhttps://twitch.tv/captain_pesky\n\nStream on January 27, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/8Kt00p5AXvc/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/8Kt00p5AXvc/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/8Kt00p5AXvc/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/8Kt00p5AXvc/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/8Kt00p5AXvc/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 28, + "resourceId": { + "kind": "youtube#video", + "videoId": "8Kt00p5AXvc" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "8Kt00p5AXvc", + "videoPublishedAt": "2026-02-26T11:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "FQi-uFKCRs0W8lgQKk713qpk_hQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLloyQ19mN2RSQkNr", + "snippet": { + "publishedAt": "2026-02-26T07:52:43Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "REPO with funny people :D", + "description": "Guests:\nhttps://twitch.tv/annameison\nhttps://twitch.tv/luminere_flash\nhttps://twitch.tv/captain_pesky\nhttps://twitch.tv/flowaphenyx017\nhttps://twitch.tv/masutifu_\nVinny\nStallo\n\nStream on January 24, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Z2C_f7dRBCk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Z2C_f7dRBCk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Z2C_f7dRBCk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Z2C_f7dRBCk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Z2C_f7dRBCk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 29, + "resourceId": { + "kind": "youtube#video", + "videoId": "Z2C_f7dRBCk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Z2C_f7dRBCk", + "videoPublishedAt": "2026-02-26T07:52:43Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "uVpqXNraQzuJaFelm8OmDmq58LY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjRiWG5Od0Z3MFM4", + "snippet": { + "publishedAt": "2026-02-25T17:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Hytale Server! even more exploration", + "description": "Stream on January 23 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/4bXnNwFw0S8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/4bXnNwFw0S8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/4bXnNwFw0S8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/4bXnNwFw0S8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/4bXnNwFw0S8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 30, + "resourceId": { + "kind": "youtube#video", + "videoId": "4bXnNwFw0S8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "4bXnNwFw0S8", + "videoPublishedAt": "2026-02-25T17:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "EjNNL0t3sXFJd2Tda6eeimVI5Lg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLm9ZbldXckZCNS1J", + "snippet": { + "publishedAt": "2026-02-25T11:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Hytale Server! more exploration of what the game has to offer", + "description": "Stream on January 18, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/oYnWWrFB5-I/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/oYnWWrFB5-I/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/oYnWWrFB5-I/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/oYnWWrFB5-I/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/oYnWWrFB5-I/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 31, + "resourceId": { + "kind": "youtube#video", + "videoId": "oYnWWrFB5-I" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "oYnWWrFB5-I", + "videoPublishedAt": "2026-02-25T11:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "oglsZs6rGyfrjitGykW98asKu3s", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLi1aRmdXNHZ1SmFn", + "snippet": { + "publishedAt": "2026-02-25T07:09:40Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Mech Farming Friday with @ramskelly", + "description": "Playing some Lightyear Frontier with @RamSkelly \n\nGuest:\nhttps://twitch.tv/ramskelly\n\nStream on January 16, 2026 Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/-ZFgW4vuJag/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/-ZFgW4vuJag/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/-ZFgW4vuJag/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/-ZFgW4vuJag/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/-ZFgW4vuJag/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 32, + "resourceId": { + "kind": "youtube#video", + "videoId": "-ZFgW4vuJag" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "-ZFgW4vuJag", + "videoPublishedAt": "2026-02-25T07:09:40Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "onWw7KS-Lce454T-voKk381tL-8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLndGUE02VEk5RzBB", + "snippet": { + "publishedAt": "2026-02-24T17:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Finally its here! we have waited so long!!! lets explore HYTALE", + "description": "Stream on January 13, 2026 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/wFPM6TI9G0A/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/wFPM6TI9G0A/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/wFPM6TI9G0A/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/wFPM6TI9G0A/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/wFPM6TI9G0A/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 33, + "resourceId": { + "kind": "youtube#video", + "videoId": "wFPM6TI9G0A" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "wFPM6TI9G0A", + "videoPublishedAt": "2026-02-24T17:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "AchknyCVF1XPl64xI6lyKsPhPSg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk5tNHJDVVloRlA0", + "snippet": { + "publishedAt": "2026-02-24T11:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "REPO with the One Braincell Crew - Christmas Stream 2025 Part 8", + "description": "Christmas Stream 2025 - This is Part 8 of the Christmas stream. @starsgaming , @annameison , Vinny and I are going into REPO to have the most possible chaos with just one braincell\nGiveaways were limited to the day of the stream.\nStream on December 25, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Nm4rCUYhFP4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Nm4rCUYhFP4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Nm4rCUYhFP4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Nm4rCUYhFP4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Nm4rCUYhFP4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 34, + "resourceId": { + "kind": "youtube#video", + "videoId": "Nm4rCUYhFP4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Nm4rCUYhFP4", + "videoPublishedAt": "2026-02-24T11:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "joyqj-e1gMTbv2JrMLttPNrQL9Y", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjh4Qkg0VVhoVVg4", + "snippet": { + "publishedAt": "2026-02-24T05:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "yet another hour of yapping and putting a modpack together for REPO - Christmas Stream 2025 Part 7", + "description": "Christmas Stream 2025 - This is Part 7 of the Christmas stream. @starsgaming, @annameison, Vinny and I are discussing what we are playing and putting together a modpack for REPO\nGiveaways were limited to the day of the stream.\nStream on December 25, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/8xBH4UXhUX8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/8xBH4UXhUX8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/8xBH4UXhUX8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/8xBH4UXhUX8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/8xBH4UXhUX8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 35, + "resourceId": { + "kind": "youtube#video", + "videoId": "8xBH4UXhUX8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "8xBH4UXhUX8", + "videoPublishedAt": "2026-02-24T05:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "s4s2yxxpbTTqcp8vLKONK5cKjns", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmJzclliUEFxeERr", + "snippet": { + "publishedAt": "2026-02-23T17:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Alone on the Ocean again. - Christmas Stream 2025 Part 6", + "description": "Christmas Stream 2025 - This is Part 6 of the Christmas stream. Going out on the Ocean to collect loot and save humanity again. \nGiveaways were limited to the day of the stream.\nStream on December 25, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/bsrYbPAqxDk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/bsrYbPAqxDk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/bsrYbPAqxDk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/bsrYbPAqxDk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/bsrYbPAqxDk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 36, + "resourceId": { + "kind": "youtube#video", + "videoId": "bsrYbPAqxDk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "bsrYbPAqxDk", + "videoPublishedAt": "2026-02-23T17:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "LBeYUa125g6wscstIj-6EAD2OMw", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlhZWktoQWNjTXc0", + "snippet": { + "publishedAt": "2026-02-23T11:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "FBI! Open Up! or something... We \"Try\" to do stuff in Ready or Not - Christmas Stream 2025 Part 5", + "description": "Christmas Stream 2025 - This is Part 5 of the Christmas stream. @Axoloaded and I are trying our best to restrain the suspects.\nGiveaways were limited to the day of the stream.\nStream on December 25, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/XYZKhAccMw4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/XYZKhAccMw4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/XYZKhAccMw4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/XYZKhAccMw4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/XYZKhAccMw4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 37, + "resourceId": { + "kind": "youtube#video", + "videoId": "XYZKhAccMw4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "XYZKhAccMw4", + "videoPublishedAt": "2026-02-23T11:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "m5Vb4ra8ri8sxNy17OmBkPAqK5A", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLm9rSnB2ZWhqUW9n", + "snippet": { + "publishedAt": "2026-02-23T05:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Yapping to chat while we figure out what to play - Christmas Stream 2025 Part 4", + "description": "Christmas Stream 2025 - This is Part 4 of the Christmas stream. @Axoloaded and I are once again trying to figure out what to play\nGiveaways were limited to the day of the stream.\nStream on December 25, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/okJpvehjQog/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/okJpvehjQog/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/okJpvehjQog/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/okJpvehjQog/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/okJpvehjQog/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 38, + "resourceId": { + "kind": "youtube#video", + "videoId": "okJpvehjQog" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "okJpvehjQog", + "videoPublishedAt": "2026-02-23T05:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "5WuP1Qrzq16mnBn0uzIyPHdM7gU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjVlWU1XcHg5MjFV", + "snippet": { + "publishedAt": "2026-02-22T17:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Are we gonna be able to go fully unprepared on the Rail? - Christmas Stream 2025 Part 3", + "description": "Christmas Stream 2025 - This is Part 3 of the Christmas stream. @Axoloaded and I are trying our best to not let the train derail.\nGiveaways were limited to the day of the stream.\nStream on December 25, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/5eYMWpx921U/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/5eYMWpx921U/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/5eYMWpx921U/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/5eYMWpx921U/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/5eYMWpx921U/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 39, + "resourceId": { + "kind": "youtube#video", + "videoId": "5eYMWpx921U" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "5eYMWpx921U", + "videoPublishedAt": "2026-02-22T17:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "8mC-bP_rpMZ3ydXNFyvjzWiDPIM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkExTm5ydUZzOXJR", + "snippet": { + "publishedAt": "2026-02-22T11:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Some badly played Fragpunk - Christmas Stream 2025 Part 2", + "description": "Christmas Stream 2025 - This is Part 2 of the Christmas stream. Together with @Axoloaded I try my best in Fragpunk - and Fail\nGiveaways were limited to the day of the stream.\nStream on December 25, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/A1NnruFs9rQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/A1NnruFs9rQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/A1NnruFs9rQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/A1NnruFs9rQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/A1NnruFs9rQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 40, + "resourceId": { + "kind": "youtube#video", + "videoId": "A1NnruFs9rQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "A1NnruFs9rQ", + "videoPublishedAt": "2026-02-22T11:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "jUfoayE8XT3ZKks87kWcK-OfB14", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmUxRm9ONVVGS3dV", + "snippet": { + "publishedAt": "2026-02-22T05:00:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Yapping till we figure out what we can play - Christmas Stream 2025 Part 1", + "description": "Christmas Stream 2025 - This is Part 1 of the Christmas stream. Yapping with @Axoloaded about what we are going to play.\nGiveaways were limited to the day of the stream.\nStream on December 25, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/e1FoN5UFKwU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/e1FoN5UFKwU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/e1FoN5UFKwU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/e1FoN5UFKwU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/e1FoN5UFKwU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 41, + "resourceId": { + "kind": "youtube#video", + "videoId": "e1FoN5UFKwU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "e1FoN5UFKwU", + "videoPublishedAt": "2026-02-22T05:00:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "gm60-t8xX1ycvGQwQSa3MyJOzVQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk1HRUllVXQ3Yk5Z", + "snippet": { + "publishedAt": "2026-01-10T17:00:38Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Big Progress in Minecolonies - Beyond the Horizon Modpack", + "description": "Stream on December 21, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/MGEIeUt7bNY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/MGEIeUt7bNY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/MGEIeUt7bNY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/MGEIeUt7bNY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/MGEIeUt7bNY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 42, + "resourceId": { + "kind": "youtube#video", + "videoId": "MGEIeUt7bNY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "MGEIeUt7bNY", + "videoPublishedAt": "2026-01-10T17:00:38Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "wBrqd8bDa0s_9s58dIEoMW-G7Ww", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnc1OWpkeTlTd3JZ", + "snippet": { + "publishedAt": "2026-01-10T11:01:45Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Its once again time for some Lies of P | Happy holidays everyone!", + "description": "Stream on December 21, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/w59jdy9SwrY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/w59jdy9SwrY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/w59jdy9SwrY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/w59jdy9SwrY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/w59jdy9SwrY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 43, + "resourceId": { + "kind": "youtube#video", + "videoId": "w59jdy9SwrY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "w59jdy9SwrY", + "videoPublishedAt": "2026-01-10T11:01:45Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "WFxn5czy-qy0S9RoJx8BkRIDIFY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk5tUHY3ZDB1SE9Z", + "snippet": { + "publishedAt": "2026-01-10T05:00:27Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Christmas Cleaning with @ramskelly | Happy holidays everyone!", + "description": "Stream on December 19, 2025 -- Watch live at https://www.twitch.tv/devilszone_\n\nGuest:\nhttps://twitch.tv/ramskelly", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/NmPv7d0uHOY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/NmPv7d0uHOY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/NmPv7d0uHOY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/NmPv7d0uHOY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/NmPv7d0uHOY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 44, + "resourceId": { + "kind": "youtube#video", + "videoId": "NmPv7d0uHOY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "NmPv7d0uHOY", + "videoPublishedAt": "2026-01-10T05:00:27Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Fgu8aMPKhMtVK7bJh9BRQQPvB0Q", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLktxQmdBbnRQdHNV", + "snippet": { + "publishedAt": "2026-01-09T17:00:38Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Exploration time! plus a little more progress on my Minecolony", + "description": "Stream on December 16, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/KqBgAntPtsU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/KqBgAntPtsU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/KqBgAntPtsU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/KqBgAntPtsU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/KqBgAntPtsU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 45, + "resourceId": { + "kind": "youtube#video", + "videoId": "KqBgAntPtsU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "KqBgAntPtsU", + "videoPublishedAt": "2026-01-09T17:00:38Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "o3Z8dO9Gjo2cKoUAcGqkHOQgqaE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkplaXdnWWNiYkJB", + "snippet": { + "publishedAt": "2026-01-09T11:00:41Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "My colony calls again - Minecolonies in Beyond The Horizon", + "description": "Stream on December 14, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/JeiwgYcbbBA/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/JeiwgYcbbBA/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/JeiwgYcbbBA/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/JeiwgYcbbBA/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/JeiwgYcbbBA/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 46, + "resourceId": { + "kind": "youtube#video", + "videoId": "JeiwgYcbbBA" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "JeiwgYcbbBA", + "videoPublishedAt": "2026-01-09T11:00:41Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "mqszd-gmXkVmmgMUL4rxCycNHaE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmdEcUc2bURSdmQ0", + "snippet": { + "publishedAt": "2026-01-09T05:00:48Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Scrapping everything we can find | The Last Caretaker", + "description": "Stream on December 14, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/gDqG6mDRvd4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/gDqG6mDRvd4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/gDqG6mDRvd4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/gDqG6mDRvd4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/gDqG6mDRvd4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 47, + "resourceId": { + "kind": "youtube#video", + "videoId": "gDqG6mDRvd4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "gDqG6mDRvd4", + "videoPublishedAt": "2026-01-09T05:00:48Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "mxS1QxcbdNk1NqZ2FC8UHDbB9KQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnF4bVJFY29pYmxz", + "snippet": { + "publishedAt": "2026-01-08T17:01:29Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "trying to get the Copper cape - Minecraft Bedrock playtime", + "description": "Stream on December 14, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/qxmREcoibls/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/qxmREcoibls/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/qxmREcoibls/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/qxmREcoibls/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/qxmREcoibls/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 48, + "resourceId": { + "kind": "youtube#video", + "videoId": "qxmREcoibls" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "qxmREcoibls", + "videoPublishedAt": "2026-01-08T17:01:29Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "I6zCMnvKw9U-Alp4rF8nqJrGU7w", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkJPWkpqV2dBdnF3", + "snippet": { + "publishedAt": "2025-12-29T11:00:19Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Beyond the Horizon - its time to start a new Minecraft Modpack!", + "description": "Private Modpack server, put together by https://twitch.tv/moosemanex ( @MoosemanEX )\n\nStream on December 13, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/BOZJjWgAvqw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/BOZJjWgAvqw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/BOZJjWgAvqw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/BOZJjWgAvqw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/BOZJjWgAvqw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 49, + "resourceId": { + "kind": "youtube#video", + "videoId": "BOZJjWgAvqw" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "BOZJjWgAvqw", + "videoPublishedAt": "2025-12-29T11:00:19Z" + }, + "status": { + "privacyStatus": "public" + } + } + ], + "pageInfo": { + "totalResults": 405, + "resultsPerPage": 50 + } + }, + { + "kind": "youtube#playlistItemListResponse", + "etag": "1oezvqe21ZAUgDAoetVvwplHHUg", + "nextPageToken": "EAAaBlBUOkNHUQ", + "prevPageToken": "EAEaBlBUOkNESQ", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "F1b8mKJumG-MatCkaQzQobDF_iI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLktxQmdBbnRQdHNV", + "snippet": { + "publishedAt": "2026-01-09T17:00:38Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Exploration time! plus a little more progress on my Minecolony", + "description": "Stream on December 16, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/KqBgAntPtsU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/KqBgAntPtsU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/KqBgAntPtsU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/KqBgAntPtsU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/KqBgAntPtsU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 50, + "resourceId": { + "kind": "youtube#video", + "videoId": "KqBgAntPtsU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "KqBgAntPtsU", + "videoPublishedAt": "2026-01-09T17:00:38Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "cjS-mSqHt78GR8g9O58ag-JhBvs", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkJPWkpqV2dBdnF3", + "snippet": { + "publishedAt": "2025-12-29T11:00:19Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Beyond the Horizon - its time to start a new Minecraft Modpack!", + "description": "Private Modpack server, put together by https://twitch.tv/moosemanex ( @MoosemanEX )\n\nStream on December 13, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/BOZJjWgAvqw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/BOZJjWgAvqw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/BOZJjWgAvqw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/BOZJjWgAvqw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/BOZJjWgAvqw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 51, + "resourceId": { + "kind": "youtube#video", + "videoId": "BOZJjWgAvqw" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "BOZJjWgAvqw", + "videoPublishedAt": "2025-12-29T11:00:19Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "5pqDsq81VbJPr-FISf6hneYWCHM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmNfaE5RTG9kTDJn", + "snippet": { + "publishedAt": "2025-12-28T11:01:10Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Time to grow some more Humans | The Last Caretaker", + "description": "December 7, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/c_hNQLodL2g/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/c_hNQLodL2g/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/c_hNQLodL2g/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/c_hNQLodL2g/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/c_hNQLodL2g/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 52, + "resourceId": { + "kind": "youtube#video", + "videoId": "c_hNQLodL2g" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "c_hNQLodL2g", + "videoPublishedAt": "2025-12-28T11:01:10Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "_eFAtR4E3Q1FiN8tqeLhBzNc5cE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjZDYllGQ0xoTEFF", + "snippet": { + "publishedAt": "2025-12-28T05:00:14Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Cleaning Friday! Are we up for this amount again? Finishing Unearthly Excevations", + "description": "Finishing Unearthly Excevations - Sadly we couldnt find bob...\n\nGuest:\nhttps://twitch.tv/ramskelly (@RamSkelly)\n\nStream on December 5, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/6CbYFCLhLAE/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/6CbYFCLhLAE/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/6CbYFCLhLAE/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/6CbYFCLhLAE/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/6CbYFCLhLAE/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 53, + "resourceId": { + "kind": "youtube#video", + "videoId": "6CbYFCLhLAE" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "6CbYFCLhLAE", + "videoPublishedAt": "2025-12-28T05:00:14Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "uapLaFsX84GxudDZUy8_APndfq8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlRDTmFJdExDbjNF", + "snippet": { + "publishedAt": "2025-12-27T17:00:53Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Taking care (or something) and launching that rocket!", + "description": "Stream on November 30, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/TCNaItLCn3E/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/TCNaItLCn3E/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/TCNaItLCn3E/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/TCNaItLCn3E/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/TCNaItLCn3E/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 54, + "resourceId": { + "kind": "youtube#video", + "videoId": "TCNaItLCn3E" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "TCNaItLCn3E", + "videoPublishedAt": "2025-12-27T17:00:53Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "FFY0KyLfsq5lngPhvNpMX4dpys0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmFuSm55YnpEUF9Z", + "snippet": { + "publishedAt": "2025-12-27T11:00:11Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Throwing @RamSkelly into the new REPO update on a high level... SORRY", + "description": "Some more REPO, this time with ramskelly, who is a little overwhelmed by a level 9 run with not much experience....\n\nGuests:\nhttps://twitch.tv/ramskelly (@RamSkelly)\nhttps://twitch.tv/annameison (@annameison)\n\nStream on November 28, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/anJnybzDP_Y/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/anJnybzDP_Y/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/anJnybzDP_Y/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/anJnybzDP_Y/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/anJnybzDP_Y/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 55, + "resourceId": { + "kind": "youtube#video", + "videoId": "anJnybzDP_Y" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "anJnybzDP_Y", + "videoPublishedAt": "2025-12-27T11:00:11Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "XG1qI2nPh-8oCaoP8k1HxxVelfE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjdtN0ljcXZwTnBz", + "snippet": { + "publishedAt": "2025-12-27T05:00:07Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Checking out the voting for the Streamer-Awards 2025", + "description": "Stream on November 28, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/7m7IcqvpNps/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/7m7IcqvpNps/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/7m7IcqvpNps/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/7m7IcqvpNps/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/7m7IcqvpNps/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 56, + "resourceId": { + "kind": "youtube#video", + "videoId": "7m7IcqvpNps" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "7m7IcqvpNps", + "videoPublishedAt": "2025-12-27T05:00:07Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "9ij8ktUEuhyUBcrgAEPl2huYcYY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLm4tLXh5dkpjaWJV", + "snippet": { + "publishedAt": "2025-12-26T17:01:20Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Preparing the rocket to save a human | The Last Caretaker", + "description": "Stream on November 25, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/n--xyvJcibU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/n--xyvJcibU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/n--xyvJcibU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/n--xyvJcibU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/n--xyvJcibU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 57, + "resourceId": { + "kind": "youtube#video", + "videoId": "n--xyvJcibU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "n--xyvJcibU", + "videoPublishedAt": "2025-12-26T17:01:20Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "FFEDnzdcohyddR5yZGuCEN8J01Q", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkhDQ3BrenRhTkdv", + "snippet": { + "publishedAt": "2025-12-26T11:00:37Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Escort the Human, Find the Rocket | The Last Caretaker", + "description": "Stream on November 24, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/HCCpkztaNGo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/HCCpkztaNGo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/HCCpkztaNGo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/HCCpkztaNGo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/HCCpkztaNGo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 58, + "resourceId": { + "kind": "youtube#video", + "videoId": "HCCpkztaNGo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "HCCpkztaNGo", + "videoPublishedAt": "2025-12-26T11:00:37Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "w2NRr1Uqb-l6ndNO42nYoUaAjBA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmFpMFlvQzVWWHhz", + "snippet": { + "publishedAt": "2025-12-26T05:00:01Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "We found the Seedvault, can we save a human? | The Last Caretaker", + "description": "Stream on November 21, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/ai0YoC5VXxs/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/ai0YoC5VXxs/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/ai0YoC5VXxs/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/ai0YoC5VXxs/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/ai0YoC5VXxs/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 59, + "resourceId": { + "kind": "youtube#video", + "videoId": "ai0YoC5VXxs" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "ai0YoC5VXxs", + "videoPublishedAt": "2025-12-26T05:00:01Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "ziJgXlooxZiYrEjgtOITEKN4ghE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkxGbUNTSkN4MDBF", + "snippet": { + "publishedAt": "2025-12-25T17:00:34Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Let there be roads | Roadcraft", + "description": "Finally crawling closer to finishing the map!\n\nGuest:\nhttps://twitch.tv/scarofman (@Scarofman)\n\nStream on November 19, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/LFmCSJCx00E/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/LFmCSJCx00E/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/LFmCSJCx00E/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/LFmCSJCx00E/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/LFmCSJCx00E/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 60, + "resourceId": { + "kind": "youtube#video", + "videoId": "LFmCSJCx00E" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "LFmCSJCx00E", + "videoPublishedAt": "2025-12-25T17:00:34Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "0Pkz-R0pJ6uoKl1VsoT5JJ8YIOw", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLi1VOEFfZjhRUWs4", + "snippet": { + "publishedAt": "2025-12-25T11:00:03Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "The hunt for the seed vault | The Last Caretaker", + "description": "We start the hunt for the seed vault can we find it?\n\nStream on November 16, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/-U8A_f8QQk8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/-U8A_f8QQk8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/-U8A_f8QQk8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/-U8A_f8QQk8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/-U8A_f8QQk8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 61, + "resourceId": { + "kind": "youtube#video", + "videoId": "-U8A_f8QQk8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "-U8A_f8QQk8", + "videoPublishedAt": "2025-12-25T11:00:03Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "9bcn9rClfIi25uhXaaAPSoOQ1E8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLldnaTVaNWxCU3BB", + "snippet": { + "publishedAt": "2025-12-25T05:00:45Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Looting and Scapping all we can get in The Last Caretaker", + "description": "we continue on our quest to save humanity in The Last Caretaker!\n\nStream on November 15, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Wgi5Z5lBSpA/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Wgi5Z5lBSpA/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Wgi5Z5lBSpA/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Wgi5Z5lBSpA/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Wgi5Z5lBSpA/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 62, + "resourceId": { + "kind": "youtube#video", + "videoId": "Wgi5Z5lBSpA" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Wgi5Z5lBSpA", + "videoPublishedAt": "2025-12-25T05:00:45Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "BSNIupWj3770sIsMeNrf3LBE604", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlZxLUJmRDVCbmJV", + "snippet": { + "publishedAt": "2025-12-24T17:00:53Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Starting a new Server on Survive the Nights | w/ @everthewhimsywitch @chana_banana", + "description": "Together with @EvertheWhimsyWitch and @chana_banana we are starting a new Survive The Nights Server! Lets have some fun!\n\nGuests:\nhttps://twitch.tv/chana_banana (@chana_banana)\nhttps://twitch.tv/everthewhimsywitch (@EvertheWhimsyWitch)\n\nStream on November 15, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Vq-BfD5BnbU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Vq-BfD5BnbU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Vq-BfD5BnbU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Vq-BfD5BnbU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Vq-BfD5BnbU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 63, + "resourceId": { + "kind": "youtube#video", + "videoId": "Vq-BfD5BnbU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Vq-BfD5BnbU", + "videoPublishedAt": "2025-12-24T17:00:53Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "TQC3nkSzdCDXQy2rfQcFjEdvNc0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnhiVFlQRVcxUjVn", + "snippet": { + "publishedAt": "2025-12-24T11:02:04Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "THANK YOU for 500 Twitch Followers! | Playing the Follower-Goal \"Lies of P\"", + "description": "500 Twitch Followers? This is crazy! Thank you to everyone who made it possible. And all of this in just 2 short years. Incredible.\n\nNow its time to make good on my promise, to play the game \"Lies of P\", my first time playing a Souls-Like...\n\nStream on November 15, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/xbTYPEW1R5g/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/xbTYPEW1R5g/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/xbTYPEW1R5g/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/xbTYPEW1R5g/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/xbTYPEW1R5g/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 64, + "resourceId": { + "kind": "youtube#video", + "videoId": "xbTYPEW1R5g" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "xbTYPEW1R5g", + "videoPublishedAt": "2025-12-24T11:02:04Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "HmEtK9ZKkVSdUwGXofqmnAT25CQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnQyaW5NSEk5cldn", + "snippet": { + "publishedAt": "2025-12-24T05:01:10Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Taking care of humanity again | The Last Caretaker", + "description": "Stream on November 14, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/t2inMHI9rWg/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/t2inMHI9rWg/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/t2inMHI9rWg/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/t2inMHI9rWg/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/t2inMHI9rWg/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 65, + "resourceId": { + "kind": "youtube#video", + "videoId": "t2inMHI9rWg" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "t2inMHI9rWg", + "videoPublishedAt": "2025-12-24T05:01:10Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "zysZOosI3BicE3OGCB22V0_J41o", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmVHRl9zUUN5NDR3", + "snippet": { + "publishedAt": "2025-12-23T17:01:13Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "its finally Cleaning.. Wednesday?? | Viscera Cleanup Detail with @ramskelly @annameison", + "description": "Finally we managed to get a Viscera Stream together again.\n\nGuests:\nhttps://twitch.tv/annameison\nhttps://twitch.tv/ramskelly\n\nStream on November 12, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/eGF_sQCy44w/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/eGF_sQCy44w/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/eGF_sQCy44w/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/eGF_sQCy44w/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/eGF_sQCy44w/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 66, + "resourceId": { + "kind": "youtube#video", + "videoId": "eGF_sQCy44w" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "eGF_sQCy44w", + "videoPublishedAt": "2025-12-23T17:01:13Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Hnx17hbfzmEteTuPehKmMwOS79Y", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmQ2ei1ILTRnSWRr", + "snippet": { + "publishedAt": "2025-12-23T11:01:16Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "We continue to explore the REPO monster update", + "description": "Getting more looks into the Monster Update, this time with more people\n\nGuests:\nhttps://twitch.tv/luminere_flash\nhttps://twitch.tv/annameison\nhttps://twitch.tv/thetainz\nVnincnent\nYour_Personal_Potato\nElmosgotarpg\n\nStream on November 8, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/d6z-H-4gIdk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/d6z-H-4gIdk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/d6z-H-4gIdk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/d6z-H-4gIdk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/d6z-H-4gIdk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 67, + "resourceId": { + "kind": "youtube#video", + "videoId": "d6z-H-4gIdk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "d6z-H-4gIdk", + "videoPublishedAt": "2025-12-23T11:01:16Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "qlLCAPVQ1Vv3V5o-vLG66466mfk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnVKRXRWUjRkRHI4", + "snippet": { + "publishedAt": "2025-12-23T05:01:11Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Words On Stream - We got surprisingly far!", + "description": "Stream on November 8, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/uJEtVR4dDr8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/uJEtVR4dDr8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/uJEtVR4dDr8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/uJEtVR4dDr8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/uJEtVR4dDr8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 68, + "resourceId": { + "kind": "youtube#video", + "videoId": "uJEtVR4dDr8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "uJEtVR4dDr8", + "videoPublishedAt": "2025-12-23T05:01:11Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "hppAu8b_fe5oG8C2MZ2CR-do12A", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlJZOXp6M09KTy0w", + "snippet": { + "publishedAt": "2025-12-22T17:00:55Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "I might be hooked on this game | The Last Caretaker", + "description": "Continuing our quests and exploration in The Last Caretaker\n\nStream on November 8, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/RY9zz3OJO-0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/RY9zz3OJO-0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/RY9zz3OJO-0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/RY9zz3OJO-0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/RY9zz3OJO-0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 69, + "resourceId": { + "kind": "youtube#video", + "videoId": "RY9zz3OJO-0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "RY9zz3OJO-0", + "videoPublishedAt": "2025-12-22T17:00:55Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "TWbtDWWFTKmIPByBPPXGpq-SlGY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlUybV9hQ1FNVDBj", + "snippet": { + "publishedAt": "2025-12-22T11:01:02Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "We are... The last Caretaker!", + "description": "First time Playing The Last Caretaker\n\nStream on November 7, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/U2m_aCQMT0c/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/U2m_aCQMT0c/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/U2m_aCQMT0c/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/U2m_aCQMT0c/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/U2m_aCQMT0c/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 70, + "resourceId": { + "kind": "youtube#video", + "videoId": "U2m_aCQMT0c" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "U2m_aCQMT0c", + "videoPublishedAt": "2025-12-22T11:01:02Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "lOyJCbby4-xFDp3S9cLK7Oy2bZI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjFjbWxJWllUQ1Qw", + "snippet": { + "publishedAt": "2025-12-22T05:00:02Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Left4Dead2 - Advanced Difficulty - we will die. Certainly", + "description": "Continuing on our Left 4 Dead 2 Run\n\nGuests:\nhttps://twitch.tv/annameison\nBlxck_Pheonix\n\nStream on November 4, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/1cmlIZYTCT0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/1cmlIZYTCT0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/1cmlIZYTCT0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/1cmlIZYTCT0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/1cmlIZYTCT0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 71, + "resourceId": { + "kind": "youtube#video", + "videoId": "1cmlIZYTCT0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "1cmlIZYTCT0", + "videoPublishedAt": "2025-12-22T05:00:02Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "M2-IviLk9JXGj-DHcS7v-Qk1VRE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmVyNURrYmhYV29N", + "snippet": { + "publishedAt": "2025-12-21T05:00:51Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Surviving the nights again? Just a short follow up stream to raid some structures", + "description": "Stream on November 1, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/er5DkbhXWoM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/er5DkbhXWoM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/er5DkbhXWoM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/er5DkbhXWoM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/er5DkbhXWoM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 72, + "resourceId": { + "kind": "youtube#video", + "videoId": "er5DkbhXWoM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "er5DkbhXWoM", + "videoPublishedAt": "2025-12-21T05:00:51Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "hMcykTiBH24xa_rfNcq5_QmXgaA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk5QNkFlS1JzWnZZ", + "snippet": { + "publishedAt": "2025-12-20T17:00:38Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Surviving the nights? atleast thats the goal... | Halloween Streamer Event in \"Survive The Nights\"", + "description": "Streamer Event in Survive the Nights, organized by @LobitoGamingTV \n\nGuests:\nhttps://twitch.tv/chana_banana @chana_banana \nhttps://twitch.tv/everthewhimsywitch @EvertheWhimsyWitch \n\nStream on October 31, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/NP6AeKRsZvY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/NP6AeKRsZvY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/NP6AeKRsZvY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/NP6AeKRsZvY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/NP6AeKRsZvY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 73, + "resourceId": { + "kind": "youtube#video", + "videoId": "NP6AeKRsZvY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "NP6AeKRsZvY", + "videoPublishedAt": "2025-12-20T17:00:38Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "gYWaslT_n6iey97hOjZxlYE_MnQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnNqMDkyaThhS2R3", + "snippet": { + "publishedAt": "2025-12-20T11:01:53Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "REPO Monster Update! - First look | w/ @annameison", + "description": "First look at the Monster Update in R.E.P.O.\n\nGuests:\nhttps://twitch.tv/annameison\nVinny\n\nStream on October 30, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/sj092i8aKdw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/sj092i8aKdw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/sj092i8aKdw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/sj092i8aKdw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/sj092i8aKdw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 74, + "resourceId": { + "kind": "youtube#video", + "videoId": "sj092i8aKdw" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "sj092i8aKdw", + "videoPublishedAt": "2025-12-20T11:01:53Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "tS89orM-ypSjxzbYbpkjCnj0wHU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlJoc3E1RDB3UzBj", + "snippet": { + "publishedAt": "2025-12-20T05:00:37Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "its time to survive in left 4 dead 2 | w/ @annameison", + "description": "Giving Left 4 Dead 2 a go again after quite some time!\nIts gonna be fun, its gonna be nostalgic, here we go!\n\nGuest:\nhttps://twitch.tv/annameison\n\nStream on October 29, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Rhsq5D0wS0c/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Rhsq5D0wS0c/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Rhsq5D0wS0c/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Rhsq5D0wS0c/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Rhsq5D0wS0c/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 75, + "resourceId": { + "kind": "youtube#video", + "videoId": "Rhsq5D0wS0c" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Rhsq5D0wS0c", + "videoPublishedAt": "2025-12-20T05:00:37Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "5zkwjSwfhMqMJ365zTjR9iISie4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk9fWF91eGhMMGFJ", + "snippet": { + "publishedAt": "2025-12-19T17:00:45Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Playing Outlast Trials, because I really hated Alien Isolation", + "description": "After the desaster that was Alien Isolation, I am going into a chill round of Outlast Trilals with AnnaMeison\n\nGuest:\nhttps://twitch.tv/annameison\n\nStream on October 26, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/O_X_uxhL0aI/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/O_X_uxhL0aI/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/O_X_uxhL0aI/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/O_X_uxhL0aI/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/O_X_uxhL0aI/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 76, + "resourceId": { + "kind": "youtube#video", + "videoId": "O_X_uxhL0aI" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "O_X_uxhL0aI", + "videoPublishedAt": "2025-12-19T17:00:45Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "yu4MtPOLKZTkXD6DY8U6uKml7x4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjVmcGNnZHJ4U2dF", + "snippet": { + "publishedAt": "2025-12-19T11:00:12Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "I dont like Alien Isolation. This is the stream when I uninstalled it...", + "description": "I give Alien Isolation a second try. But I still absolutely dislike it...\n\nAnnameison is helping me through this... but is this really help?\n\nStream on October 26, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/5fpcgdrxSgE/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/5fpcgdrxSgE/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/5fpcgdrxSgE/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/5fpcgdrxSgE/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/5fpcgdrxSgE/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 77, + "resourceId": { + "kind": "youtube#video", + "videoId": "5fpcgdrxSgE" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "5fpcgdrxSgE", + "videoPublishedAt": "2025-12-19T11:00:12Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "oFFZobCz2y4VmCigTWbWi2waUaU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjlfZFZhVXRfR1pV", + "snippet": { + "publishedAt": "2025-12-19T08:14:19Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Big Lethal Time again! can we survive to those moons?", + "description": "Big group plays Lethal Company - Mostly Vanilla this time\n\nGuests:\nhttps://twitch.tv/flowaphenyx017 ( @FlowaPhenyx017 )\nhttps://twitch.tv/masutifu_ ( @masutifu )\nhttps://twitch.tv/stars_vr ( @starsgaming )\nhttps://twitch.tv/zuror\nhttps://twitch.tv/luminere_flash (@Luminere_flash )\n\nStream on October 25, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/9_dVaUt_GZU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/9_dVaUt_GZU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/9_dVaUt_GZU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/9_dVaUt_GZU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/9_dVaUt_GZU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 78, + "resourceId": { + "kind": "youtube#video", + "videoId": "9_dVaUt_GZU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "9_dVaUt_GZU", + "videoPublishedAt": "2025-12-19T08:14:19Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "MqJJD3M7jLQ6kzSdX8uzAnp4VyM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnAxeWt6Wl9EbXBr", + "snippet": { + "publishedAt": "2025-12-18T17:01:22Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Cleanup Friday it is! Finishing Hydroponic Hell and full clean of Frostbite", + "description": "Its once again time for Viscera Cleanup. \n\nGuest: \nhttps://twitch.tv/ramskelly\n\nStream on October 24, 2025", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/p1ykzZ_Dmpk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/p1ykzZ_Dmpk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/p1ykzZ_Dmpk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/p1ykzZ_Dmpk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/p1ykzZ_Dmpk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 79, + "resourceId": { + "kind": "youtube#video", + "videoId": "p1ykzZ_Dmpk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "p1ykzZ_Dmpk", + "videoPublishedAt": "2025-12-18T17:01:22Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "hlJj56aqDNwG4psk_p63IecUPbI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnRuTmVWc2ExZGhF", + "snippet": { + "publishedAt": "2025-12-18T11:01:38Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "First time playing LIMBO - Full playthrough", + "description": "I wanted to play LIMBO for a while, so now I am going to do it!\nIts an experience that I am happy to have taken!\n\nStream on October 19, 2025 -- Watch Live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/tnNeVsa1dhE/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/tnNeVsa1dhE/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/tnNeVsa1dhE/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/tnNeVsa1dhE/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/tnNeVsa1dhE/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 80, + "resourceId": { + "kind": "youtube#video", + "videoId": "tnNeVsa1dhE" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "tnNeVsa1dhE", + "videoPublishedAt": "2025-12-18T11:01:38Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "HLJO-oL_cZ5ST7GhQAQkUMnc4Zw", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmVxRVlJZE1hVThJ", + "snippet": { + "publishedAt": "2025-12-18T05:00:52Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "First time playing DEVOUR - what have I gotten into?", + "description": "Spicy got me into playing DEVOUR - so here we are...\n\nGuest:\nhttps://twitch.tv/axoloaded ( @Axoloaded )\nhttps://twitch.tv/spicynoobles ( @SpicyNoobles )\n\nStream on October 19, 2025 -- Watch Live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/eqEYIdMaU8I/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/eqEYIdMaU8I/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/eqEYIdMaU8I/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/eqEYIdMaU8I/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/eqEYIdMaU8I/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 81, + "resourceId": { + "kind": "youtube#video", + "videoId": "eqEYIdMaU8I" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "eqEYIdMaU8I", + "videoPublishedAt": "2025-12-18T05:00:52Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "3XIE_oSnJ1NQNk8jA1sTSYPyDWs", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlpyZDVyeXBXS1cw", + "snippet": { + "publishedAt": "2025-12-17T17:00:53Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Watching a Videos and yapping about stuff while Devour downloads", + "description": "Videos Watched:\nhttps://www.youtube.com/watch?v=OdNqWcUdm6I\nhttps://www.youtube.com/watch?v=bF9zOykErJ4\n\nGuests:\nhttps://twitch.tv/spicynoobles\nhttps://twitch.tv/axoloaded\n\nStream on October 19, 2025 -- Watch Live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Zrd5rypWKW0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Zrd5rypWKW0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Zrd5rypWKW0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Zrd5rypWKW0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Zrd5rypWKW0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 82, + "resourceId": { + "kind": "youtube#video", + "videoId": "Zrd5rypWKW0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Zrd5rypWKW0", + "videoPublishedAt": "2025-12-17T17:00:53Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "jlbLiTuQPvqGOssV4n9_dMVo2XI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnh1cnhKdTR2a0w0", + "snippet": { + "publishedAt": "2025-12-17T11:02:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Steam Next Fest - PainkillerDEMO / Testing Game Demos with @Axoloaded", + "description": "During the Steam Next Fest, @Axoloaded and I are testing a couple Horror-Games. Here we have Painkiller.\n\nGuest:\nhttps://twitch.tv/axoloaded ( @Axoloaded )\n\nStream on October 19, 2025 -- Watch Live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/xurxJu4vkL4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/xurxJu4vkL4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/xurxJu4vkL4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/xurxJu4vkL4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/xurxJu4vkL4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 83, + "resourceId": { + "kind": "youtube#video", + "videoId": "xurxJu4vkL4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "xurxJu4vkL4", + "videoPublishedAt": "2025-12-17T11:02:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Mv7nDfKfchX7MYcgSN-uWVTHNxs", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlNkLVZ2YjZvdDI0", + "snippet": { + "publishedAt": "2025-12-17T05:00:38Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Steam Next Fest - Dollhouse of Dead DEMO / Testing Game Demos with @Axoloaded", + "description": "During the Steam Next Fest, @Axoloaded and I are testing a couple Horror-Games. Here we have Dollhouse of Dead.\n\nGuest:\nhttps://twitch.tv/axoloaded ( @Axoloaded )\n\nStream on October 19, 2025 -- Watch Live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Sd-Vvb6ot24/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Sd-Vvb6ot24/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Sd-Vvb6ot24/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Sd-Vvb6ot24/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Sd-Vvb6ot24/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 84, + "resourceId": { + "kind": "youtube#video", + "videoId": "Sd-Vvb6ot24" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Sd-Vvb6ot24", + "videoPublishedAt": "2025-12-17T05:00:38Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "G5Kve6IyZDT9yzL7J72RJwmNUqA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkczRkRfRFpmYjA0", + "snippet": { + "publishedAt": "2025-12-16T17:00:31Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Steam Next Fest - YAPYAP DEMO / Testing Game Demos with @Axoloaded ​", + "description": "During the Steam Next Fest, @Axoloaded and I are testing a couple Horror-Games. Here we have YAPYAP.\n\nGuest:\nhttps://twitch.tv/axoloaded ( @Axoloaded )\n\nStream on October 19, 2025 -- Watch Live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/G3FD_DZfb04/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/G3FD_DZfb04/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/G3FD_DZfb04/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/G3FD_DZfb04/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/G3FD_DZfb04/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 85, + "resourceId": { + "kind": "youtube#video", + "videoId": "G3FD_DZfb04" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "G3FD_DZfb04", + "videoPublishedAt": "2025-12-16T17:00:31Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "4hqlZy45Ounv1WHeJjpknzbuoLM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmJhV0VsbVNuUmdF", + "snippet": { + "publishedAt": "2025-12-16T11:01:11Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Steam Next Fest - Backrooms Lost Runners DEMO / Testing Game Demos with @Axoloaded", + "description": "During the Steam Next Fest, @Axoloaded and I are testing a couple Horror-Games. Here we have Backrooms Lost Runners.\n\nGuest:\nhttps://twitch.tv/axoloaded ( @Axoloaded )\n\nStream on October 19, 2025 -- Watch Live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/baWElmSnRgE/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/baWElmSnRgE/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/baWElmSnRgE/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/baWElmSnRgE/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/baWElmSnRgE/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 86, + "resourceId": { + "kind": "youtube#video", + "videoId": "baWElmSnRgE" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "baWElmSnRgE", + "videoPublishedAt": "2025-12-16T11:01:11Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "dwtqE4Er5uDwfr52Tjc2k7Ape1g", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmFkMnhkM3VYeUgw", + "snippet": { + "publishedAt": "2025-12-16T05:00:02Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Steam Next Fest - Deadly Delivery DEMO / Testing Game Demos with @Axoloaded", + "description": "During the Steam Next Fest, @Axoloaded and I are testing a couple Horror-Games. Here we have Deadly Delivery.\n\nGuest:\nhttps://twitch.tv/axoloaded ( @Axoloaded )\n\nStream on October 19, 2025 -- Watch Live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/ad2xd3uXyH0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/ad2xd3uXyH0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/ad2xd3uXyH0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/ad2xd3uXyH0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/ad2xd3uXyH0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 87, + "resourceId": { + "kind": "youtube#video", + "videoId": "ad2xd3uXyH0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "ad2xd3uXyH0", + "videoPublishedAt": "2025-12-16T05:00:02Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "g0-Xb71GfOguL1jbhrpDobVp6RU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLm5ldW9iV29Edkdr", + "snippet": { + "publishedAt": "2025-12-15T17:01:31Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Steam Next Fest - Storebound DEMO / Testing Game Demos with ⁨@Axoloaded", + "description": "During the Steam Next Fest, @Axoloaded and I are testing a couple Horror-Games. Here we have Storebound.\n\nGuest:\nhttps://twitch.tv/axoloaded ( @Axoloaded )\n\nStream on October 19, 2025 -- Watch Live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/neuobWoDvGk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/neuobWoDvGk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/neuobWoDvGk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/neuobWoDvGk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/neuobWoDvGk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 88, + "resourceId": { + "kind": "youtube#video", + "videoId": "neuobWoDvGk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "neuobWoDvGk", + "videoPublishedAt": "2025-12-15T17:01:31Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "1zuY2CotMi9FqBDBli67sh3neak", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkdvTDZzcFZFaEVv", + "snippet": { + "publishedAt": "2025-12-01T11:00:28Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "trying and failing at getting SCP Games to work, then witching to Lethal Company with friends", + "description": "Spontaneous Lethal company with some friends\n\nStream on October 14, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/GoL6spVEhEo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/GoL6spVEhEo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/GoL6spVEhEo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/GoL6spVEhEo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/GoL6spVEhEo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 89, + "resourceId": { + "kind": "youtube#video", + "videoId": "GoL6spVEhEo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "GoL6spVEhEo", + "videoPublishedAt": "2025-12-01T11:00:28Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "5wdjAE8CAXMb-FKz_4e3qFBVg0o", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLndBVVhwblozSFNn", + "snippet": { + "publishedAt": "2025-12-01T05:01:15Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Little Nightmares 3 First time playing - Full Playthrough - Part Three / Finish", + "description": "Stream on October 12, 2025 -- Watch live at [https://twitch.tv/devilszone_](https://twitch.tv/devilszone_)", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/wAUXpnZ3HSg/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/wAUXpnZ3HSg/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/wAUXpnZ3HSg/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/wAUXpnZ3HSg/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/wAUXpnZ3HSg/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 90, + "resourceId": { + "kind": "youtube#video", + "videoId": "wAUXpnZ3HSg" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "wAUXpnZ3HSg", + "videoPublishedAt": "2025-12-01T05:01:15Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "lpfYYqNMOqx8F1e01p_AEHrIK0Y", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmRtdkhoR1dLbWVv", + "snippet": { + "publishedAt": "2025-11-29T17:01:05Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "little things on big farms while we wait", + "description": "Stream on October 12, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/dmvHhGWKmeo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/dmvHhGWKmeo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/dmvHhGWKmeo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/dmvHhGWKmeo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/dmvHhGWKmeo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 91, + "resourceId": { + "kind": "youtube#video", + "videoId": "dmvHhGWKmeo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "dmvHhGWKmeo", + "videoPublishedAt": "2025-11-29T17:01:05Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "LL-uYf5v3saMuvGBx2MCOsfuRrs", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk15NW95N0JqZVRZ", + "snippet": { + "publishedAt": "2025-11-29T11:00:42Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Big Lethal Company Collab!", + "description": "Big Lethal Company Collab!\n\nGuests: \n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n[https://twitch.tv/axoloaded](https://twitch.tv/axoloaded) \n[https://twitch.tv/flowaphenyx017](https://twitch.tv/flowaphenyx017) \n[https://twitch.tv/dr_silkmoth](https://twitch.tv/dr_silkmoth) \n[https://twitch.tv/zuror](https://twitch.tv/zuror) \n[https://twitch.tv/luminere_flash](https://twitch.tv/luminere_flash)\n\nStream on October 12, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/My5oy7BjeTY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/My5oy7BjeTY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/My5oy7BjeTY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/My5oy7BjeTY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/My5oy7BjeTY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 92, + "resourceId": { + "kind": "youtube#video", + "videoId": "My5oy7BjeTY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "My5oy7BjeTY", + "videoPublishedAt": "2025-11-29T11:00:42Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "650b3xbg0Vb38hZUemGGjBtGNzY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLndVUUkyMTZWQlRn", + "snippet": { + "publishedAt": "2025-11-29T05:01:10Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Little Nightmares 3 First time playing - Full Playthrough - Part two", + "description": "Stream on October 11, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/wUQI216VBTg/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/wUQI216VBTg/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/wUQI216VBTg/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/wUQI216VBTg/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/wUQI216VBTg/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 93, + "resourceId": { + "kind": "youtube#video", + "videoId": "wUQI216VBTg" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "wUQI216VBTg", + "videoPublishedAt": "2025-11-29T05:01:10Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "uF4Jrs8lrnRk3ubzjmqIlsOEIM4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjU2VzNXa1YzWklR", + "snippet": { + "publishedAt": "2025-11-28T17:00:08Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Little Nightmares 3 First time playing - Full Playthrough - Part One", + "description": "Stream on October 11, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/56W3WkV3ZIQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/56W3WkV3ZIQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/56W3WkV3ZIQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/56W3WkV3ZIQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/56W3WkV3ZIQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 94, + "resourceId": { + "kind": "youtube#video", + "videoId": "56W3WkV3ZIQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "56W3WkV3ZIQ", + "videoPublishedAt": "2025-11-28T17:00:08Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "3YmVl8CZ052SZfngHqEMnRuAAao", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnlzelJQY3BDZ29v", + "snippet": { + "publishedAt": "2025-11-28T11:02:05Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "INSIDE of a dark project - Full playthrough of INSIDE", + "description": "Stream on October 10, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/yszRPcpCgoo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/yszRPcpCgoo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/yszRPcpCgoo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/yszRPcpCgoo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/yszRPcpCgoo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 95, + "resourceId": { + "kind": "youtube#video", + "videoId": "yszRPcpCgoo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "yszRPcpCgoo", + "videoPublishedAt": "2025-11-28T11:02:05Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "TkmTJRK_EynyTCmQfvdRd5QH2EY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkRFQi1kWXl4LUVV", + "snippet": { + "publishedAt": "2025-11-28T05:00:15Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Once again in Once Human - Get ready to fight some dreams!", + "description": "Stream on October 8, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/DEB-dYyx-EU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/DEB-dYyx-EU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/DEB-dYyx-EU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/DEB-dYyx-EU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/DEB-dYyx-EU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 96, + "resourceId": { + "kind": "youtube#video", + "videoId": "DEB-dYyx-EU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "DEB-dYyx-EU", + "videoPublishedAt": "2025-11-28T05:00:15Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "pMlS0KDtjZP5f8_OiovF9LRQKac", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjk1RUVOU1Q2QXJJ", + "snippet": { + "publishedAt": "2025-11-12T05:00:10Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Surprise REPO Collab!", + "description": "REPO Stream with friends\n\nGuests: \n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n[https://twitch.tv/electrode13_5](https://twitch.tv/electrode13_5) \n[https://twitch.tv/reeykus](https://twitch.tv/reeykus)\n\nStream on October 7, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/95EENST6ArI/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/95EENST6ArI/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/95EENST6ArI/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/95EENST6ArI/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/95EENST6ArI/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 97, + "resourceId": { + "kind": "youtube#video", + "videoId": "95EENST6ArI" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "95EENST6ArI", + "videoPublishedAt": "2025-11-12T05:00:10Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "xkx9wCHCSL2tUXes-qqwjzuaDX0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlEwQXJiaGpyWm9Z", + "snippet": { + "publishedAt": "2025-11-11T17:00:45Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Struggles with Amnesia the bunker, then Alien Isolation", + "description": "I wasnt really fond of playing this game. the experience was horrible and I still dont like it.\n\nStream on October 6, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Q0ArbhjrZoY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Q0ArbhjrZoY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Q0ArbhjrZoY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Q0ArbhjrZoY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Q0ArbhjrZoY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 98, + "resourceId": { + "kind": "youtube#video", + "videoId": "Q0ArbhjrZoY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Q0ArbhjrZoY", + "videoPublishedAt": "2025-11-11T17:00:45Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "xIDZcCGPlWC8h9Cvel-udO2o-IM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLldVU1FhQ3lCaWVV", + "snippet": { + "publishedAt": "2025-11-11T11:01:07Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "trying to play Amnesia: The Bunker - Failed because of low framerate", + "description": "Stream on October 6, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/WUSQaCyBieU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/WUSQaCyBieU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/WUSQaCyBieU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/WUSQaCyBieU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/WUSQaCyBieU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 99, + "resourceId": { + "kind": "youtube#video", + "videoId": "WUSQaCyBieU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "WUSQaCyBieU", + "videoPublishedAt": "2025-11-11T11:01:07Z" + }, + "status": { + "privacyStatus": "public" + } + } + ], + "pageInfo": { + "totalResults": 405, + "resultsPerPage": 50 + } + }, + { + "kind": "youtube#playlistItemListResponse", + "etag": "2xV6vGgWQ1dq67J_lA1hbdjqfFg", + "nextPageToken": "EAAaB1BUOkNKWUI", + "prevPageToken": "EAEaBlBUOkNHUQ", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "xckKqcbgRGjfk8gjUhM241TSRp0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlEwQXJiaGpyWm9Z", + "snippet": { + "publishedAt": "2025-11-11T17:00:45Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Struggles with Amnesia the bunker, then Alien Isolation", + "description": "I wasnt really fond of playing this game. the experience was horrible and I still dont like it.\n\nStream on October 6, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Q0ArbhjrZoY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Q0ArbhjrZoY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Q0ArbhjrZoY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Q0ArbhjrZoY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Q0ArbhjrZoY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 100, + "resourceId": { + "kind": "youtube#video", + "videoId": "Q0ArbhjrZoY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Q0ArbhjrZoY", + "videoPublishedAt": "2025-11-11T17:00:45Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "V3UXRGvOs9_i91Q65di1nVyA-7E", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLldVU1FhQ3lCaWVV", + "snippet": { + "publishedAt": "2025-11-11T11:01:07Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "trying to play Amnesia: The Bunker - Failed because of low framerate", + "description": "Stream on October 6, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/WUSQaCyBieU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/WUSQaCyBieU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/WUSQaCyBieU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/WUSQaCyBieU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/WUSQaCyBieU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 101, + "resourceId": { + "kind": "youtube#video", + "videoId": "WUSQaCyBieU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "WUSQaCyBieU", + "videoPublishedAt": "2025-11-11T11:01:07Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "vCp9EMIyHo2mxE0JZWgS80YxpfI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlpvTGtCNmlOd0U4", + "snippet": { + "publishedAt": "2025-11-11T05:00:45Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "its once again time for once human - maybe we can kill a \"light dreamer\"??", + "description": "Stream on October 1, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/ZoLkB6iNwE8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/ZoLkB6iNwE8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/ZoLkB6iNwE8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/ZoLkB6iNwE8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/ZoLkB6iNwE8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 102, + "resourceId": { + "kind": "youtube#video", + "videoId": "ZoLkB6iNwE8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "ZoLkB6iNwE8", + "videoPublishedAt": "2025-11-11T05:00:45Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Np-pGa8Jfcx6sRnXKrjNUyPOsTQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLllzVXlmVW1zVFFv", + "snippet": { + "publishedAt": "2025-11-10T17:01:03Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Vanilla REPO with Vinny and Anna", + "description": "Stream on September 27, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/YsUyfUmsTQo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/YsUyfUmsTQo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/YsUyfUmsTQo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/YsUyfUmsTQo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/YsUyfUmsTQo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 103, + "resourceId": { + "kind": "youtube#video", + "videoId": "YsUyfUmsTQo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "YsUyfUmsTQo", + "videoPublishedAt": "2025-11-10T17:01:03Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "AuWm3yDzF34GvkidgBplq2BFVjY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLm0xSUkydUo2OTBV", + "snippet": { + "publishedAt": "2025-10-23T16:01:29Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Watching and reacting to Minecraft Live and then playing Minecraft", + "description": "Stream on September 27, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/m1II2uJ690U/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/m1II2uJ690U/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/m1II2uJ690U/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/m1II2uJ690U/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/m1II2uJ690U/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 104, + "resourceId": { + "kind": "youtube#video", + "videoId": "m1II2uJ690U" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "m1II2uJ690U", + "videoPublishedAt": "2025-10-23T16:01:29Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "qm0BQQLNSvHaoeA5z1Jzd7UgDy4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkhRbl82aUFLQWVR", + "snippet": { + "publishedAt": "2025-10-22T04:00:16Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "OnceHuman - Endless Dream", + "description": "Stream on September 27, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/HQn_6iAKAeQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/HQn_6iAKAeQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/HQn_6iAKAeQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/HQn_6iAKAeQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/HQn_6iAKAeQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 105, + "resourceId": { + "kind": "youtube#video", + "videoId": "HQn_6iAKAeQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "HQn_6iAKAeQ", + "videoPublishedAt": "2025-10-22T04:00:16Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "7e-cUbtg9bbVJjMJw25ChuKMLa0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmZiUUhKS25QcUxv", + "snippet": { + "publishedAt": "2025-10-21T10:01:09Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "The weekly cleaning time has arrrived! Is your space clean?", + "description": "After being stupid and resetting the Items in our office, we restarted the full clean.\nFull Clean of \"Athenas Wrath\" and \"Overgrowth\"\nPartial Clean of \"Hydroponics Hell\"\n\nGuest:\nhttps://twitch.tv/ramskelly\n\nStream on September 26, 2025 -- watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/fbQHJKnPqLo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/fbQHJKnPqLo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/fbQHJKnPqLo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/fbQHJKnPqLo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/fbQHJKnPqLo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 106, + "resourceId": { + "kind": "youtube#video", + "videoId": "fbQHJKnPqLo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "fbQHJKnPqLo", + "videoPublishedAt": "2025-10-21T10:01:09Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "VZRrSAaai7hJsTgKqG5Tc1YbaK4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmQ5cW1TN1RFLTVJ", + "snippet": { + "publishedAt": "2025-10-21T04:00:47Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Repoing with friends - and suddenly we were more", + "description": "We just continued our run with Anna and Vinny... And suddenly we got a collab with Captain Pesky\n\nGuests:\nhttps://twitch.tv/annameison\nhttps://twitch.tv/captain_pesky\nhttps://twitch.tv/dr_silkmoth\nhttps://twitch.tv/unnamed_mime\n\nStream on September 23, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/d9qmS7TE-5I/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/d9qmS7TE-5I/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/d9qmS7TE-5I/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/d9qmS7TE-5I/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/d9qmS7TE-5I/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 107, + "resourceId": { + "kind": "youtube#video", + "videoId": "d9qmS7TE-5I" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "d9qmS7TE-5I", + "videoPublishedAt": "2025-10-21T04:00:47Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "3lvBWysrsMpx5OV5yKuKhSkeaFs", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLm1PaVAxRTU5XzVJ", + "snippet": { + "publishedAt": "2025-10-20T16:01:27Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Continuing the Endless Dream run in Once Human", + "description": "Continuing the Endless Dream run in Once Human.\n\nStream on September 21, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/mOiP1E59_5I/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/mOiP1E59_5I/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/mOiP1E59_5I/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/mOiP1E59_5I/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/mOiP1E59_5I/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 108, + "resourceId": { + "kind": "youtube#video", + "videoId": "mOiP1E59_5I" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "mOiP1E59_5I", + "videoPublishedAt": "2025-10-20T16:01:27Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "O5lSDNphNd4q7dkTLs5BWw4I5Z8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkkyY1BqVWxucGNJ", + "snippet": { + "publishedAt": "2025-10-20T10:00:25Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Digging up some ressources for the Nether Highway!", + "description": "Digging up some ressources for the Nether Highway!\n\nGuest:\nhttps://twitch.tv/masutifu_\n\nStream on September 21, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/I2cPjUlnpcI/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/I2cPjUlnpcI/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/I2cPjUlnpcI/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/I2cPjUlnpcI/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/I2cPjUlnpcI/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 109, + "resourceId": { + "kind": "youtube#video", + "videoId": "I2cPjUlnpcI" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "I2cPjUlnpcI", + "videoPublishedAt": "2025-10-20T10:00:25Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "MV8lCDL9VdkXvOJi4NbipFNjyBI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnVWS3g3SXktb0FV", + "snippet": { + "publishedAt": "2025-10-20T04:01:09Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Cuphead Stream takeover by @masutifu @Masutifu_VODS", + "description": "Masutifu was taking over my stream for the time I had to be abscent. \nBIG THANKS TO HIM!!!!\n\ntakeover by https://twitch.tv/masutifu_\n\nStream on September 21, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/uVKx7Iy-oAU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/uVKx7Iy-oAU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/uVKx7Iy-oAU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/uVKx7Iy-oAU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/uVKx7Iy-oAU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 110, + "resourceId": { + "kind": "youtube#video", + "videoId": "uVKx7Iy-oAU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "uVKx7Iy-oAU", + "videoPublishedAt": "2025-10-20T04:01:09Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "soMceFUXNe4mKPAwQY3S77Y6vvQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjlwQTF0RUg4bnFJ", + "snippet": { + "publishedAt": "2025-10-19T16:00:29Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "going further in the facility - other levels?", + "description": "Abiotic Factor with friends. figuring out what needs to be done to go further.\n\nGuest:\nhttps://twitch.tv/axoloaded\nhttps://twitch.tv/jarothorn\n\nStream on September 21, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/9pA1tEH8nqI/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/9pA1tEH8nqI/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/9pA1tEH8nqI/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/9pA1tEH8nqI/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/9pA1tEH8nqI/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 111, + "resourceId": { + "kind": "youtube#video", + "videoId": "9pA1tEH8nqI" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "9pA1tEH8nqI", + "videoPublishedAt": "2025-10-19T16:00:29Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Vv-eh_SpmK2oc_lUGo3tT3DE_Ng", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk1fVkZ6Y2RtWjVB", + "snippet": { + "publishedAt": "2025-10-19T10:00:28Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Once Human - a fresh start after month of absence", + "description": "Stream on September 20, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/M_VFzcdmZ5A/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/M_VFzcdmZ5A/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/M_VFzcdmZ5A/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/M_VFzcdmZ5A/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/M_VFzcdmZ5A/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 112, + "resourceId": { + "kind": "youtube#video", + "videoId": "M_VFzcdmZ5A" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "M_VFzcdmZ5A", + "videoPublishedAt": "2025-10-19T10:00:28Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "6BJ26U1aMVSXEfILuweJRST60oQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmhsUWlvX2ZKekYw", + "snippet": { + "publishedAt": "2025-10-19T04:00:48Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Cleanup Crew on the run - Cryogenesis", + "description": "Full clean of Cryogenesis in Viscera Cleanup Detail\nStream on September 19, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/hlQio_fJzF0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/hlQio_fJzF0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/hlQio_fJzF0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/hlQio_fJzF0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/hlQio_fJzF0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 113, + "resourceId": { + "kind": "youtube#video", + "videoId": "hlQio_fJzF0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "hlQio_fJzF0", + "videoPublishedAt": "2025-10-19T04:00:48Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "sZRxMMsz2KHUWejz_Met2Hsd6-k", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkEwS2RhWmlYVDJv", + "snippet": { + "publishedAt": "2025-10-18T16:00:14Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Only me and my car in the Olympic Exclusion Zone - First time playing Pacific Drive", + "description": "My First time in Pacific drive - see how bad I am in it...\n\nStream on September 16, 2025 -- watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/A0KdaZiXT2o/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/A0KdaZiXT2o/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/A0KdaZiXT2o/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/A0KdaZiXT2o/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/A0KdaZiXT2o/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 114, + "resourceId": { + "kind": "youtube#video", + "videoId": "A0KdaZiXT2o" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "A0KdaZiXT2o", + "videoPublishedAt": "2025-10-18T16:00:14Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "NFpT_r3XELAJComDlsqE9lHo1eM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjZNU0ZGeU9qMkRr", + "snippet": { + "publishedAt": "2025-10-18T10:00:18Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Blacktail - finish", + "description": "Finishing our first time playing Blacktail\n\nStream on September 14, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/6MSFFyOj2Dk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/6MSFFyOj2Dk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/6MSFFyOj2Dk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/6MSFFyOj2Dk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/6MSFFyOj2Dk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 115, + "resourceId": { + "kind": "youtube#video", + "videoId": "6MSFFyOj2Dk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "6MSFFyOj2Dk", + "videoPublishedAt": "2025-10-18T10:00:18Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "f2dOrKlOU83hRSHFeQmz9eVJ-jA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmc4YjVpeHN1b1Fr", + "snippet": { + "publishedAt": "2025-10-18T04:00:45Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Getting over it and yapping about random stuff", + "description": "getting pulled into some getting over it (I do not enjoy this game) and just chatting about random stuff. \n\nGuest:\nhttps://twitch.tv/axoloaded\n\nStream on September 14, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/g8b5ixsuoQk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/g8b5ixsuoQk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/g8b5ixsuoQk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/g8b5ixsuoQk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/g8b5ixsuoQk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 116, + "resourceId": { + "kind": "youtube#video", + "videoId": "g8b5ixsuoQk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "g8b5ixsuoQk", + "videoPublishedAt": "2025-10-18T04:00:45Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "anxnq1urxrjsqbtCnOnCgufTtUg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkRBckVvWV9jbXQ0", + "snippet": { + "publishedAt": "2025-10-17T16:00:23Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Surviving in a research facility w/ @axoloaded @jarothorn - First time playing Abiotic Factor", + "description": "First time playing Abiotic Factor\n\nGuests:\nhttps://twitch.tv/axoloaded\nhttps://twitch.tv/jarothorn\n\nStream on September 14, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/DArEoY_cmt4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/DArEoY_cmt4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/DArEoY_cmt4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/DArEoY_cmt4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/DArEoY_cmt4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 117, + "resourceId": { + "kind": "youtube#video", + "videoId": "DArEoY_cmt4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "DArEoY_cmt4", + "videoPublishedAt": "2025-10-17T16:00:23Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "_n1JuAat6uXbIwb8gtGK5bejCo4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnc2SWNrWldxTExZ", + "snippet": { + "publishedAt": "2025-10-17T10:01:43Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Its time for a cleaning! Viscera everywhere... Unearthly Excevations full clean", + "description": "Viscera friday! Unearthly Excevations is on the plan for today\n\nStream on September 12, 2025", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/w6IckZWqLLY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/w6IckZWqLLY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/w6IckZWqLLY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/w6IckZWqLLY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/w6IckZWqLLY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 118, + "resourceId": { + "kind": "youtube#video", + "videoId": "w6IckZWqLLY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "w6IckZWqLLY", + "videoPublishedAt": "2025-10-17T10:01:43Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "O2mw0NuzLww8_Bs3Ly_Qt6DvEe0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlhuVTFBaHVxSHl3", + "snippet": { + "publishedAt": "2025-10-17T04:00:43Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "REPO again! its been too long...", + "description": "REPO with friends\n\nGuest:\nhttps://twitch.tv/annameison\n\nStream on September 9, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/XnU1AhuqHyw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/XnU1AhuqHyw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/XnU1AhuqHyw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/XnU1AhuqHyw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/XnU1AhuqHyw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 119, + "resourceId": { + "kind": "youtube#video", + "videoId": "XnU1AhuqHyw" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "XnU1AhuqHyw", + "videoPublishedAt": "2025-10-17T04:00:43Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "S57qoYDlzGYltYFwKH4_e8uvuZY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlJhRGhfeWYzcWs4", + "snippet": { + "publishedAt": "2025-10-16T16:00:49Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "First time playing Blacktail! Lets play an archery game with a kinda broken mouse", + "description": "First time playing\nStream on September 7, 2025 -- Watch live on Twitch at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/RaDh_yf3qk8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/RaDh_yf3qk8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/RaDh_yf3qk8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/RaDh_yf3qk8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/RaDh_yf3qk8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 120, + "resourceId": { + "kind": "youtube#video", + "videoId": "RaDh_yf3qk8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "RaDh_yf3qk8", + "videoPublishedAt": "2025-10-16T16:00:49Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "6rmoxME-6KOEgUh5weo0hxoMzOs", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkpWbnhNN19CMklN", + "snippet": { + "publishedAt": "2025-10-16T10:00:36Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Viscera Cleanup Detail - Splatter Station - Short Cleaning Stream", + "description": "Guest:\nhttps://twitch.tv/ramskelly\n\nStream on September 3, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/JVnxM7_B2IM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/JVnxM7_B2IM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/JVnxM7_B2IM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/JVnxM7_B2IM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/JVnxM7_B2IM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 121, + "resourceId": { + "kind": "youtube#video", + "videoId": "JVnxM7_B2IM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "JVnxM7_B2IM", + "videoPublishedAt": "2025-10-16T10:00:36Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Mc-TENw89t54FCrO0OEBmsMatC4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnVlT2J4NjE0VWpB", + "snippet": { + "publishedAt": "2025-10-16T04:01:17Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Some chill Minecrafting - Working on little things in the base", + "description": "Stream on September 3, 2025\n\nBroadcasted live on Twitch -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/ueObx614UjA/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/ueObx614UjA/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/ueObx614UjA/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/ueObx614UjA/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/ueObx614UjA/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 122, + "resourceId": { + "kind": "youtube#video", + "videoId": "ueObx614UjA" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "ueObx614UjA", + "videoPublishedAt": "2025-10-16T04:01:17Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "X7rxnhEDHR9tnFTJufxVu-5P7U4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLno3djhJQzViSGhV", + "snippet": { + "publishedAt": "2025-10-15T20:47:29Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "OnceHuman outro - no public - September 21, 2025", + "description": "Broadcasted live on Twitch -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/z7v8IC5bHhU/default.jpg?sqp=CJSIwdAG&rs=AOn4CLCWr8w0oxE1l9g8D22a2L9NfxIbMA", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/z7v8IC5bHhU/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDxhm8TlV5ZXySCKM5TGb1XWVBmww", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/z7v8IC5bHhU/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBC0kC0ijpzyIo_Hws4gXO0PqZhfw", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/z7v8IC5bHhU/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBw6nVFUfdUmRHXJDzGJc_3vW1WHQ", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/z7v8IC5bHhU/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBNX5vWHWhXEPEh3Aq5UezDfHakEA", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 123, + "resourceId": { + "kind": "youtube#video", + "videoId": "z7v8IC5bHhU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "z7v8IC5bHhU", + "videoPublishedAt": "2025-10-15T20:47:29Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "cI4zhBCovcluu35EtFPucLDSsLg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkI1MVk3eGtzS2d3", + "snippet": { + "publishedAt": "2025-09-30T10:00:30Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Building on the Kelp-Farm on the Minecraft-Server", + "description": "Building on the Kelp-Farm\n\nStream on September 2, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/B51Y7xksKgw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/B51Y7xksKgw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/B51Y7xksKgw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/B51Y7xksKgw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/B51Y7xksKgw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 124, + "resourceId": { + "kind": "youtube#video", + "videoId": "B51Y7xksKgw" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "B51Y7xksKgw", + "videoPublishedAt": "2025-09-30T10:00:30Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "TZaaU4mUdj1LYmX88QQN3khLfTY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmZkMVRvRE5pYm1r", + "snippet": { + "publishedAt": "2025-09-30T04:00:52Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "\"Make Good Choices\" - I dont think I am capable of that", + "description": "Make good choices - the new level \"Havana\"\n\nStream on September 2, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/fd1ToDNibmk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/fd1ToDNibmk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/fd1ToDNibmk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/fd1ToDNibmk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/fd1ToDNibmk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 125, + "resourceId": { + "kind": "youtube#video", + "videoId": "fd1ToDNibmk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "fd1ToDNibmk", + "videoPublishedAt": "2025-09-30T04:00:52Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "lGDaUKygt-KcIDeyRu2JvXlOXrY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlFQdThiN1E0ZDdN", + "snippet": { + "publishedAt": "2025-09-29T16:00:45Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Crafting some Roads and other infrastructure with @scarofman", + "description": "Roadcraft on the map Incommunicado with scarofman\n\nStream on August 19, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/QPu8b7Q4d7M/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/QPu8b7Q4d7M/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/QPu8b7Q4d7M/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/QPu8b7Q4d7M/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/QPu8b7Q4d7M/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 126, + "resourceId": { + "kind": "youtube#video", + "videoId": "QPu8b7Q4d7M" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "QPu8b7Q4d7M", + "videoPublishedAt": "2025-09-29T16:00:45Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "CuaUoKeZOVUGArhnK4CGkv2l6qI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkUzSE5SS1JPaXZ3", + "snippet": { + "publishedAt": "2025-09-29T10:00:32Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Lets continue on our run for money for the taxman! REPO Time here we go", + "description": "REPO with Vinny and Anna\n\nGuest:\nhttps://twitch.tv/annameison\n\nStream on August 17, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/E3HNRKROivw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/E3HNRKROivw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/E3HNRKROivw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/E3HNRKROivw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/E3HNRKROivw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 127, + "resourceId": { + "kind": "youtube#video", + "videoId": "E3HNRKROivw" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "E3HNRKROivw", + "videoPublishedAt": "2025-09-29T10:00:32Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "G2XD6od8qzrdrpmaNVoNGHmxHxU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjFUb05GcHZjbUtj", + "snippet": { + "publishedAt": "2025-09-29T04:00:05Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Viscera Cleanup - Full Clean of Hydroponic Hell", + "description": "Minecraft then Viscera cleanup map Hydroponic Hell\n\nGuests:\nhttps://twitch.tv/annameison\nhttps://twitch.tv/ramskelly\n\nStream on August 15, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/1ToNFpvcmKc/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/1ToNFpvcmKc/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/1ToNFpvcmKc/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/1ToNFpvcmKc/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/1ToNFpvcmKc/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 128, + "resourceId": { + "kind": "youtube#video", + "videoId": "1ToNFpvcmKc" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "1ToNFpvcmKc", + "videoPublishedAt": "2025-09-29T04:00:05Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "6jnCLm-CLWwss173iR8DhaZB6uM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkprc1QxNXNKQWpz", + "snippet": { + "publishedAt": "2025-09-28T16:00:37Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Time to work on the Froglight-Farm!", + "description": "Working on finishing the Froglight-Farm!\n\nStream on August 15, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/JksT15sJAjs/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/JksT15sJAjs/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/JksT15sJAjs/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/JksT15sJAjs/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/JksT15sJAjs/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 129, + "resourceId": { + "kind": "youtube#video", + "videoId": "JksT15sJAjs" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "JksT15sJAjs", + "videoPublishedAt": "2025-09-28T16:00:37Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "RQh568u3HtigF6NGIUWGbULrLAI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLm5sNE9wVWlraVhR", + "snippet": { + "publishedAt": "2025-09-27T04:01:04Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "REPO - A new run", + "description": "Starting a fresh run in REPO with Anna and Vinny\n\nGuest:\nhttps://twitch.tv/annameison\n\nStream on August 12, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/nl4OpUikiXQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/nl4OpUikiXQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/nl4OpUikiXQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/nl4OpUikiXQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/nl4OpUikiXQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 130, + "resourceId": { + "kind": "youtube#video", + "videoId": "nl4OpUikiXQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "nl4OpUikiXQ", + "videoPublishedAt": "2025-09-27T04:01:04Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "gNVuwfVnDzN5YkyNhhwHycmUL8E", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkhDMEpiUmxOQVNR", + "snippet": { + "publishedAt": "2025-09-26T16:00:39Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Oooga booga booga! Its time to be a space janitor again!", + "description": "Viscera Cleanup - Finishing the Level \"Revolutionary Robotics\"\n\nGuest:\nhttps://twitch.tv/ramskelly\nhttps://twitch.tv/annameison\n\nStream on August 12, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/HC0JbRlNASQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/HC0JbRlNASQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/HC0JbRlNASQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/HC0JbRlNASQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/HC0JbRlNASQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 131, + "resourceId": { + "kind": "youtube#video", + "videoId": "HC0JbRlNASQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "HC0JbRlNASQ", + "videoPublishedAt": "2025-09-26T16:00:39Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "wOsUolrSJGOIsafsM9MNceAjCno", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkZLOVZHc21pZC0w", + "snippet": { + "publishedAt": "2025-09-26T10:00:29Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Its minecraft time again!", + "description": "Minecraft on the Community Server with SpicyNoobles\n\nGuest: \n[https://twitch.tv/spicynoobles](https://twitch.tv/spicynoobles)\n\nStream on August 10, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/FK9VGsmid-0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/FK9VGsmid-0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/FK9VGsmid-0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/FK9VGsmid-0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/FK9VGsmid-0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 132, + "resourceId": { + "kind": "youtube#video", + "videoId": "FK9VGsmid-0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "FK9VGsmid-0", + "videoPublishedAt": "2025-09-26T10:00:29Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "jXVdLv0eKfteUWStVYDHKRkFDkE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjBmbmVDUHVDMC00", + "snippet": { + "publishedAt": "2025-09-26T04:00:01Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Some swatting in hard mode! I am not sure if I am Ready Or Not", + "description": "Ready or not Hard Mode with friends\n\nGuests: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n[https://twitch.tv/kyuvulpes](https://twitch.tv/kyuvulpes)\n\nStream on August 6, 2025 -- Watch live at [https://twitch.tv/devilszone_](https://twitch.tv/devilszone_)", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/0fneCPuC0-4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/0fneCPuC0-4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/0fneCPuC0-4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/0fneCPuC0-4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/0fneCPuC0-4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 133, + "resourceId": { + "kind": "youtube#video", + "videoId": "0fneCPuC0-4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "0fneCPuC0-4", + "videoPublishedAt": "2025-09-26T04:00:01Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "4uVGecJow55zmnqfYA7T4aelSc0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLngwOUdsalBIYmJv", + "snippet": { + "publishedAt": "2025-09-25T16:01:44Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "The Borderlands are calling again - Supporting Lumis 24h stream!", + "description": "We are supporting Lumis 24h stream with some Borderlands 2\nThe stream is raising funds for K9 Focus\nCheck them out here: https://www.k9focus.co.uk\n\nGuests: \nhttps://twitch.tv/luminere_flash\n\nStream on August 2, 2025 -- watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/x09GljPHbbo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/x09GljPHbbo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/x09GljPHbbo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/x09GljPHbbo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/x09GljPHbbo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 134, + "resourceId": { + "kind": "youtube#video", + "videoId": "x09GljPHbbo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "x09GljPHbbo", + "videoPublishedAt": "2025-09-25T16:01:44Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "lvFC0Zo086QbuxRqN9Q51CKJi5k", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnR0c2M5T1N4VEhr", + "snippet": { + "publishedAt": "2025-09-25T10:01:40Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Chaos in the Company - Supporting Lumis 24h Stream", + "description": "Chaos in Lethal Company!\nWe are supporting Lumis Lethal Company Stream, which raises funds for K9 Focus\nCheck them out here: https://www.k9focus.co.uk\n\nGuests:\nhttps://twitch.tv/luminere_flash\nhttps://twitch.tv/axoloaded\nhttps://twitch.tv/stars_vr\n\nStream on August 2, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/ttsc9OSxTHk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/ttsc9OSxTHk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/ttsc9OSxTHk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/ttsc9OSxTHk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/ttsc9OSxTHk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 135, + "resourceId": { + "kind": "youtube#video", + "videoId": "ttsc9OSxTHk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "ttsc9OSxTHk", + "videoPublishedAt": "2025-09-25T10:01:40Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "lLhSSivpVpIPRt-Zr-_-WcQ1jns", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkFoaUZMaUprVGxz", + "snippet": { + "publishedAt": "2025-09-25T04:00:20Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "REPO with da friendos! Supporting Lumis 24h Stream", + "description": "Lumi was doing her 24h Stream and we were supporting her in parts of it.\nThe 24h Stream was raising funds for K9 Focus. \nCheck them out here: https://www.k9focus.co.uk\n\nGuests:\nhttps://twitch.tv/luminere_flash\nhttps://twitch.tv/flowaphenyx017\n\nStream on August 2, 2025 -- Watch live at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/AhiFLiJkTls/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/AhiFLiJkTls/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/AhiFLiJkTls/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/AhiFLiJkTls/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/AhiFLiJkTls/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 136, + "resourceId": { + "kind": "youtube#video", + "videoId": "AhiFLiJkTls" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "AhiFLiJkTls", + "videoPublishedAt": "2025-09-25T04:00:20Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "NnAEYOaKrrh4z0qYYNL0pvk1r90", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmk0QUlYUFRpeVJJ", + "snippet": { + "publishedAt": "2025-09-22T10:22:03Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Welcoming a new community to the Community Minecraft Server | @spicynoobles", + "description": "The \"Devil and friends\" Comunity server got a new Community joining!\nSpicyNoobles joined the fun with their community and this is the first stream for that!\n\nGuest:\nhttps://twitch.tv/spicynoobles\n\nStream on August 1, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/i4AIXPTiyRI/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/i4AIXPTiyRI/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/i4AIXPTiyRI/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/i4AIXPTiyRI/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/i4AIXPTiyRI/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 137, + "resourceId": { + "kind": "youtube#video", + "videoId": "i4AIXPTiyRI" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "i4AIXPTiyRI", + "videoPublishedAt": "2025-09-22T10:22:03Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "KRcszZm6t2FXwg_8b3EQpkOBeqA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlNFLXdKRE1FdEhv", + "snippet": { + "publishedAt": "2025-09-20T04:00:27Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "some tired and chill minecraft time on the community server", + "description": "Stream on July 29, 2025 -- Watch live on Twitch: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/SE-wJDMEtHo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/SE-wJDMEtHo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/SE-wJDMEtHo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/SE-wJDMEtHo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/SE-wJDMEtHo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 138, + "resourceId": { + "kind": "youtube#video", + "videoId": "SE-wJDMEtHo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "SE-wJDMEtHo", + "videoPublishedAt": "2025-09-20T04:00:27Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "1jQcFxX4YOR8JT60dLgnlBtAwTA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjRNeWhQU2tRWm9N", + "snippet": { + "publishedAt": "2025-09-19T16:00:07Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "SWAT Sunday! We are swatting the bad guys in Ready or Not", + "description": "Guest:\nhttps://twitch.tv/kyuvulpes\nhttps://twitch.tv/annameison\nStream on July 27, 2025 -- Watch live on twitch: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/4MyhPSkQZoM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/4MyhPSkQZoM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/4MyhPSkQZoM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/4MyhPSkQZoM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/4MyhPSkQZoM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 139, + "resourceId": { + "kind": "youtube#video", + "videoId": "4MyhPSkQZoM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "4MyhPSkQZoM", + "videoPublishedAt": "2025-09-19T16:00:07Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "efFaId6eNeyt208t_liBU4rzZNk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmNUWUdaeUtIenlN", + "snippet": { + "publishedAt": "2025-09-19T10:01:13Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "RRRREEEEEPPPPOOOOOO - Levels 14-16", + "description": "Playing Levels 14-16 with friends in REPO vanilla\nStream on July 26, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/cTYGZyKHzyM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/cTYGZyKHzyM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/cTYGZyKHzyM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/cTYGZyKHzyM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/cTYGZyKHzyM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 140, + "resourceId": { + "kind": "youtube#video", + "videoId": "cTYGZyKHzyM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "cTYGZyKHzyM", + "videoPublishedAt": "2025-09-19T10:01:13Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "pml-NBr-rn6qSWImfIQAqDwYFZY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkNCYmoxQy1jMGpN", + "snippet": { + "publishedAt": "2025-09-19T04:00:31Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Continuing the Kelp-Farm", + "description": "Stream on July 26, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/CBbj1C-c0jM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/CBbj1C-c0jM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/CBbj1C-c0jM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/CBbj1C-c0jM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/CBbj1C-c0jM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 141, + "resourceId": { + "kind": "youtube#video", + "videoId": "CBbj1C-c0jM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "CBbj1C-c0jM", + "videoPublishedAt": "2025-09-19T04:00:31Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "fVNSJfnjksKDnBWcSzk4Yw7KZSg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk1jXzd3N1FQYmRn", + "snippet": { + "publishedAt": "2025-09-18T16:00:41Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Cleanup Time! Viscera Cleanup with @ramskelly and @annameison", + "description": "Finishing Gravity Drive and starting on Revolutionary Robotics\nStream on July 25, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Mc_7w7QPbdg/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Mc_7w7QPbdg/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Mc_7w7QPbdg/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Mc_7w7QPbdg/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Mc_7w7QPbdg/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 142, + "resourceId": { + "kind": "youtube#video", + "videoId": "Mc_7w7QPbdg" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Mc_7w7QPbdg", + "videoPublishedAt": "2025-09-18T16:00:41Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Drl-vLUAvOcBmd3tjVana5IyDO4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLl94T3I5b0dvcXlF", + "snippet": { + "publishedAt": "2025-09-18T10:01:07Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Some Minecraft while we wait", + "description": "Stream on July 25, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/_xOr9oGoqyE/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/_xOr9oGoqyE/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/_xOr9oGoqyE/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/_xOr9oGoqyE/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/_xOr9oGoqyE/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 143, + "resourceId": { + "kind": "youtube#video", + "videoId": "_xOr9oGoqyE" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "_xOr9oGoqyE", + "videoPublishedAt": "2025-09-18T10:01:07Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "7xs7SME6YF6fkL68q4Fq96XwFaM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnpxQWxqVlJSQVk0", + "snippet": { + "publishedAt": "2025-09-18T04:01:21Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "BABA? Is that you?", + "description": "Baba is you confusion\n\nStream on July 22, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/zqAljVRRAY4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/zqAljVRRAY4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/zqAljVRRAY4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/zqAljVRRAY4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/zqAljVRRAY4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 144, + "resourceId": { + "kind": "youtube#video", + "videoId": "zqAljVRRAY4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "zqAljVRRAY4", + "videoPublishedAt": "2025-09-18T04:01:21Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "NGbti1YHxZnLKBUBIi4INKKZlOo", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnhjcHJzbFNydjVZ", + "snippet": { + "publishedAt": "2025-09-17T16:01:49Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Supporting Axos 24hour stream! Some Friendly fun in REPO - Troubles wont stop us", + "description": "Supporting the 24h stream from Axoloaded. \n\nREPO with friends - some troubles wont stop us!\n\nGuests: \n\n[https://twitch.tv/axoloaded](https://twitch.tv/axoloaded) \n\n[https://twitch.tv/spicynoobles](https://twitch.tv/spicynoobles) \n\n[https://twitch.tv/flowaphenyx017](https://twitch.tv/flowaphenyx017) \n\n[https://twitch.tv/luminere_flash](https://twitch.tv/luminere_flash)\n\nStream on July 16, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/xcprslSrv5Y/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/xcprslSrv5Y/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/xcprslSrv5Y/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/xcprslSrv5Y/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/xcprslSrv5Y/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 145, + "resourceId": { + "kind": "youtube#video", + "videoId": "xcprslSrv5Y" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "xcprslSrv5Y", + "videoPublishedAt": "2025-09-17T16:01:49Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "mI8uxn7ZNbDb0FIb1yITVkW7y74", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkswOEFBVFRyY0Yw", + "snippet": { + "publishedAt": "2025-09-17T10:00:38Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Supporting Axos 24hour stream! Lets go insane in the Mental Institute", + "description": "Supporting the 24h stream from Axoloaded. \n\ngoing crazy in Outlast Trials\n\nGuests: \n\n[https://twitch.tv/axoloaded](https://twitch.tv/axoloaded) \n\n[https://twitch.tv/spicynoobles](https://twitch.tv/spicynoobles) \n\nStream on July 16, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/K08AATTrcF0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/K08AATTrcF0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/K08AATTrcF0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/K08AATTrcF0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/K08AATTrcF0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 146, + "resourceId": { + "kind": "youtube#video", + "videoId": "K08AATTrcF0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "K08AATTrcF0", + "videoPublishedAt": "2025-09-17T10:00:38Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "zKCkh906pvvyVbO6yB4Xx58A6qQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkUxdy1qMzljUFcw", + "snippet": { + "publishedAt": "2025-09-17T04:00:31Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Mining, Crafting, Building and exploring. Atleast that was the plan", + "description": "Some casual minecraft on the Community Minecraft Server\n\nStream on July 15, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/E1w-j39cPW0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/E1w-j39cPW0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/E1w-j39cPW0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/E1w-j39cPW0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/E1w-j39cPW0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 147, + "resourceId": { + "kind": "youtube#video", + "videoId": "E1w-j39cPW0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "E1w-j39cPW0", + "videoPublishedAt": "2025-09-17T04:00:31Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "wwBRoVZfBRcqddgBuKix5zYjx4U", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjAyWkZzV1lfdzZR", + "snippet": { + "publishedAt": "2025-09-16T16:00:01Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Minecraft time on the Community Server", + "description": "its time for some chill minecraft on the Community Server\n\nStream on July 13, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/02ZFsWY_w6Q/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/02ZFsWY_w6Q/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/02ZFsWY_w6Q/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/02ZFsWY_w6Q/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/02ZFsWY_w6Q/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 148, + "resourceId": { + "kind": "youtube#video", + "videoId": "02ZFsWY_w6Q" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "02ZFsWY_w6Q", + "videoPublishedAt": "2025-09-16T16:00:01Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "K6S3EVGmHmb8WvhSLk9k6FPLBSU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkJsREdmd2NPT3Rz", + "snippet": { + "publishedAt": "2025-09-16T10:00:17Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Stick-Fight with friends - I havent played this in way too long", + "description": "Stick-Fight the Game. Long time no play, but today its time for some chaos again!\n\nGuests: \n\n[https://twitch.tv/axoloaded](https://twitch.tv/axoloaded) \n\n[https://twitch.tv/jarothorn](https://twitch.tv/jarothorn)\n\nStream on July 13, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/BlDGfwcOOts/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/BlDGfwcOOts/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/BlDGfwcOOts/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/BlDGfwcOOts/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/BlDGfwcOOts/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 149, + "resourceId": { + "kind": "youtube#video", + "videoId": "BlDGfwcOOts" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "BlDGfwcOOts", + "videoPublishedAt": "2025-09-16T10:00:17Z" + }, + "status": { + "privacyStatus": "public" + } + } + ], + "pageInfo": { + "totalResults": 405, + "resultsPerPage": 50 + } + }, + { + "kind": "youtube#playlistItemListResponse", + "etag": "XtHTrt0t9HVLHcfedpaTBP4Pd_Q", + "nextPageToken": "EAAaB1BUOkNNZ0I", + "prevPageToken": "EAEaB1BUOkNKWUI", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "BOyb0cCWg-RAQOeO1wUvqRkJJUk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLllQdGpfMDZLbm9j", + "snippet": { + "publishedAt": "2025-09-16T04:00:40Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "its time for more VR! REPO in VR!!! REPO Gameplay with REPOXR", + "description": "First time trying the REPOXR mod! \n\nmod-link: [https://thunderstore.io/c/repo/p/DaXcess/RepoXR](https://thunderstore.io/c/repo/p/DaXcess/RepoXR)\n\nGuests: \n\n[https://twitch.tv/stars_vr](https://twitch.tv/stars_vr) \n\n[https://twitch.tv/annieeee_an](https://twitch.tv/annieeee_an)\n\n[https://twitch.tv/FlowaPhenyx017](https://twitch.tv/FlowaPhenyx017)\n\n[https://twitch.tv/luminere_flash](https://twitch.tv/luminere_flash)\n\nStream on July 11, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/YPtj_06Knoc/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/YPtj_06Knoc/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/YPtj_06Knoc/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/YPtj_06Knoc/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/YPtj_06Knoc/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 150, + "resourceId": { + "kind": "youtube#video", + "videoId": "YPtj_06Knoc" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "YPtj_06Knoc", + "videoPublishedAt": "2025-09-16T04:00:40Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "KGxMhdxOsOufoPASV-OtYpMpNiw", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjBRVHROcnBjb1hz", + "snippet": { + "publishedAt": "2025-09-15T16:00:03Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "VRChat World Hopping! Lets go and find some cool worlds", + "description": "hopping around the world, finding new ones and enjoying time with friends in VRChat\n\nGuests: \n\n[https://twitch.tv/Dr_SilkMoth](https://twitch.tv/Dr_SilkMoth)\n\nStream on July 8, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/0QTtNrpcoXs/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/0QTtNrpcoXs/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/0QTtNrpcoXs/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/0QTtNrpcoXs/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/0QTtNrpcoXs/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 151, + "resourceId": { + "kind": "youtube#video", + "videoId": "0QTtNrpcoXs" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "0QTtNrpcoXs", + "videoPublishedAt": "2025-09-15T16:00:03Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "FQpZDpyoKeUZDga3ebx5305qigY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLndkc0NpeENDOXVR", + "snippet": { + "publishedAt": "2025-09-15T10:01:25Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Monday Stream? Monday Stream! we are doing a Monday Stream! its REPO Time!", + "description": "Just some fun REPO with friends!\n\nGuests:\n\n[https://twitch.tv/annameison](https://twitch.tv/annameison)\n\n[https://twitch.tv/tartarusofhades](https://twitch.tv/tartarusofhades)\n\nStream on July 07, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/wdsCixCC9uQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/wdsCixCC9uQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/wdsCixCC9uQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/wdsCixCC9uQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/wdsCixCC9uQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 152, + "resourceId": { + "kind": "youtube#video", + "videoId": "wdsCixCC9uQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "wdsCixCC9uQ", + "videoPublishedAt": "2025-09-15T10:01:25Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "k37dRc-7T0Hn2qmJDH2eVAQoZ4I", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkplOEgxMjJFbkVn", + "snippet": { + "publishedAt": "2025-08-11T06:07:49Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Trying to play Ready or Not - but my system crashes", + "description": "Broadcasted live on Twitch -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/Je8H122EnEg/default.jpg?sqp=CJSIwdAG&rs=AOn4CLCK9X2QOQFvooru7wh6ztDxT9PlvA", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/Je8H122EnEg/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCJCmkyMcCn5raF-z58dY8NDmZ39w", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/Je8H122EnEg/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBvXALok2VUpqa6DGi44rnJ4YTt-A", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/Je8H122EnEg/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCynkzZ5kpsV2DDIP1XJ0A5Ksk4DQ", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/Je8H122EnEg/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCPN_jd6O_gYpMeAAeDXTa9tgmBqA", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 153, + "resourceId": { + "kind": "youtube#video", + "videoId": "Je8H122EnEg" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Je8H122EnEg", + "videoPublishedAt": "2025-08-11T06:07:49Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "XZ_Uw0JoYpNYJKHMSM855aEpOok", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnIyeW9CdVlGNkk4", + "snippet": { + "publishedAt": "2025-08-11T05:58:07Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Cleaning Time! or is it stealing? REPO with Friends", + "description": "REPO Base-Game with Friends\n\nLevel 1-9\n\nGuests:\n\n[https://twitch.tv/annameison](https://twitch.tv/annameison)\n\nStream on July 4, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/r2yoBuYF6I8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/r2yoBuYF6I8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/r2yoBuYF6I8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/r2yoBuYF6I8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/r2yoBuYF6I8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 154, + "resourceId": { + "kind": "youtube#video", + "videoId": "r2yoBuYF6I8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "r2yoBuYF6I8", + "videoPublishedAt": "2025-08-11T05:58:07Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "uFgvRZt4mv0AH9o5sWfCUDIJpZ4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnQ3aUh1ZDA0SVhN", + "snippet": { + "publishedAt": "2025-07-15T16:01:25Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Cleaning time! once more we go into the mess", + "description": "Chill Cleaning of the Gravity lab incident in Viscera Cleanup Detail\n\nGuests:\n\n[https://twitch.tv/ramskelly](https://twitch.tv/ramskelly)\n\n[https://twitch.tv/annameison](https://twitch.tv/annameison)\n\nStream on July 1, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/t7iHud04IXM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/t7iHud04IXM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/t7iHud04IXM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/t7iHud04IXM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/t7iHud04IXM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 155, + "resourceId": { + "kind": "youtube#video", + "videoId": "t7iHud04IXM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "t7iHud04IXM", + "videoPublishedAt": "2025-07-15T16:01:25Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "VfKdWdyrsP6vYqoyBEJF_8JNN7A", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnRpR0REX2FfSkV3", + "snippet": { + "publishedAt": "2025-07-15T10:01:32Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Random Stuff on the Minecraft Community Server", + "description": "Chill gaming and Working on the Minecraft Community server\n\nStream on June 25, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/tiGDD_a_JEw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/tiGDD_a_JEw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/tiGDD_a_JEw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/tiGDD_a_JEw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/tiGDD_a_JEw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 156, + "resourceId": { + "kind": "youtube#video", + "videoId": "tiGDD_a_JEw" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "tiGDD_a_JEw", + "videoPublishedAt": "2025-07-15T10:01:32Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "4T1t5hlb7Ik3nl9PxfeasgGbADY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlpUbVVlLWxsU1RZ", + "snippet": { + "publishedAt": "2025-07-15T04:00:45Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "More Roads! lets make the swamp a propper road | w/ @scarofman", + "description": "Finishing the Swamp Road and continuing in the quests in Raodcraft\n\nGuest: \n\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman)\n\nStream on June 24, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/ZTmUe-llSTY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/ZTmUe-llSTY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/ZTmUe-llSTY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/ZTmUe-llSTY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/ZTmUe-llSTY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 157, + "resourceId": { + "kind": "youtube#video", + "videoId": "ZTmUe-llSTY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "ZTmUe-llSTY", + "videoPublishedAt": "2025-07-15T04:00:45Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "216fiCGJOB3Tt2qOX6gUscWYEI8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkNBek5WM2tWcVhB", + "snippet": { + "publishedAt": "2025-07-14T16:00:16Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Asphalt time! lets make the swamp a propper road | w/ @scarofman", + "description": "Chill Asphalting of a Swamp in Roadcraft\n\nGuest: \n\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman)\n\nStream on June 16, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/CAzNV3kVqXA/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/CAzNV3kVqXA/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/CAzNV3kVqXA/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/CAzNV3kVqXA/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/CAzNV3kVqXA/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 158, + "resourceId": { + "kind": "youtube#video", + "videoId": "CAzNV3kVqXA" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "CAzNV3kVqXA", + "videoPublishedAt": "2025-07-14T16:00:16Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "h29hVTfU7jf7wYhzGmHHIBl_95M", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnIzdTI2TWZMNXJR", + "snippet": { + "publishedAt": "2025-06-17T10:01:30Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "SAND SAND SAND - Building Sand Roads in the Swamp in ROADCRAFT | w/ @scarofman", + "description": "Building some Sand-Roads with @scarofman in Roadcraft \n\nSwamp? better build a Road!\n\nGuest: \nhttps://twitch.tv/scarofman\n\nStream on June 16, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/r3u26MfL5rQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/r3u26MfL5rQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/r3u26MfL5rQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/r3u26MfL5rQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/r3u26MfL5rQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 159, + "resourceId": { + "kind": "youtube#video", + "videoId": "r3u26MfL5rQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "r3u26MfL5rQ", + "videoPublishedAt": "2025-06-17T10:01:30Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "lryhypXgntcYAANYWeaXjruMWCs", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnE3YXJ2TWRkakFB", + "snippet": { + "publishedAt": "2025-06-16T16:01:15Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "some eepy REPO with @captain_pesky @dr_silkmoth @kastellaart @flowaphenyx017 @Qteekitty", + "description": "REPO Beta gameplay \n\nexploring new mechanics and having fun\n\nGuests: \n\nhttps://twitch.tv/captain_pesky\nhttps://twitch.tv/dr_silkmoth\nhttps://twitch.tv/flowaphenyx @FlowaPhenyx017 \nhttps://twitch.tv/kastellaart \nhttps://twitch.tv/qteekitty @Qteekitty99 \n\nStream on June 10, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/q7arvMddjAA/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/q7arvMddjAA/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/q7arvMddjAA/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/q7arvMddjAA/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/q7arvMddjAA/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 160, + "resourceId": { + "kind": "youtube#video", + "videoId": "q7arvMddjAA" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "q7arvMddjAA", + "videoPublishedAt": "2025-06-16T16:01:15Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "liqZbbBW1xhwDBId8CtWSnjzDEM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmpBei1zUE1BNVM4", + "snippet": { + "publishedAt": "2025-06-16T12:01:18Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Watching Ducks and yapping - Waiting for stream to fully start", + "description": "REPO Beta gameplay \n\nexploring new mechanics and having fun\n\nGuests: \n\nhttps://twitch.tv/captain_pesky\nhttps://twitch.tv/dr_silkmoth\nhttps://twitch.tv/kastellaart\n\nStream on June 10, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/jAz-sPMA5S8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/jAz-sPMA5S8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/jAz-sPMA5S8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/jAz-sPMA5S8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/jAz-sPMA5S8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 161, + "resourceId": { + "kind": "youtube#video", + "videoId": "jAz-sPMA5S8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "jAz-sPMA5S8", + "videoPublishedAt": "2025-06-16T12:01:18Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "NjerzGzNAwtYd95kmOpn6NYQd5A", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkVBT3hmNXh0cG40", + "snippet": { + "publishedAt": "2025-06-05T10:00:25Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "After a lot of missplanning and replaning, its time for cleaning again! | w/ @ramskelly @annameison", + "description": "Finally another Viscera Cleanup Detail run \n\nFull Clean of the Map Overgrowth \n\nFull Clean of the Map Incubation Emergency\n\nGrind to 100% the Game\n\nGuests: \n\nhttps://twitch.tv/ramskelly\n\nhttps://twitch.tv/annameison\n\nStream on June 3, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/EAOxf5xtpn4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/EAOxf5xtpn4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/EAOxf5xtpn4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/EAOxf5xtpn4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/EAOxf5xtpn4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 162, + "resourceId": { + "kind": "youtube#video", + "videoId": "EAOxf5xtpn4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "EAOxf5xtpn4", + "videoPublishedAt": "2025-06-05T10:00:25Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "vY6cStwKXYm7gq7ldgFh0L8h1hs", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnl5Z0ctMGhEUmpJ", + "snippet": { + "publishedAt": "2025-06-05T04:01:15Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "REPO with @annameison - unplanned REPO fun", + "description": "Casual playing in REPO with @annameison \nHaving fun and doing stupid things\n\nGuest:\nhttps://twitch.tv/annameison\n\nStream on May 29, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/yygG-0hDRjI/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/yygG-0hDRjI/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/yygG-0hDRjI/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/yygG-0hDRjI/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/yygG-0hDRjI/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 163, + "resourceId": { + "kind": "youtube#video", + "videoId": "yygG-0hDRjI" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "yygG-0hDRjI", + "videoPublishedAt": "2025-06-05T04:01:15Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "GVeuqc9FGTAUawWOZ-xdFxDMpJ8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkg4Sy16UW16Qkx3", + "snippet": { + "publishedAt": "2025-06-04T16:00:24Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Speedy Minecraft! Duo Speedrun with @dfoxgaming | never did this before it usually takes me 2 weeks", + "description": "Trying to "Speedrun" Minecraft with @Dfoxgaming\n\nGuest:\n\nhttps://twitch.tv/dfoxgaming @dfoxgamingvideos \n\nStream on May 29, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/H8K-zQmzBLw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/H8K-zQmzBLw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/H8K-zQmzBLw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/H8K-zQmzBLw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/H8K-zQmzBLw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 164, + "resourceId": { + "kind": "youtube#video", + "videoId": "H8K-zQmzBLw" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "H8K-zQmzBLw", + "videoPublishedAt": "2025-06-04T16:00:24Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Kuia9uJiZdXqdXVITnVlW_IJ3t0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmZNUXQzTnkyczZZ", + "snippet": { + "publishedAt": "2025-06-04T10:01:07Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Crafting some Roads! No Recover | w/ @scarofman", + "description": "Roadcraft \n\n- Finishing the map Aftermath\n\n- Starting the map Incommunication\n\nGuest: \nhttps://twitch.tv/scarofman\n\nStream on May 27, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/fMQt3Ny2s6Y/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/fMQt3Ny2s6Y/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/fMQt3Ny2s6Y/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/fMQt3Ny2s6Y/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/fMQt3Ny2s6Y/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 165, + "resourceId": { + "kind": "youtube#video", + "videoId": "fMQt3Ny2s6Y" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "fMQt3Ny2s6Y", + "videoPublishedAt": "2025-06-04T10:01:07Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "YzCQvIvEQXj_emcQz6HwOD7TNT4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkxEa29xYl9qTjNZ", + "snippet": { + "publishedAt": "2025-06-04T04:00:23Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "12 hours of modded R.E.P.O.! Chat added mods to the modlist during stream", + "description": "Chat added mods to the modlist during stream\nREPO modded with a lot of friends.\n\nGuests:\nhttps://twitch.tv/jarothron @Jarothorn \nhttps://twitch.tv/moosemanex @MoosemanEX \nhttps://twitch.tv/annameison @annameison \nhttps://twitch.tv/axoloaded @Axoloaded \nhttps://twitch.tv/zuror\nhttps://twitch.tv/stars_vr @starsgaming \nhttps://twitch.tv/dr_silkmoth\n\nStream on May 24, 2025", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/LDkoqb_jN3Y/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/LDkoqb_jN3Y/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/LDkoqb_jN3Y/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/LDkoqb_jN3Y/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/LDkoqb_jN3Y/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 166, + "resourceId": { + "kind": "youtube#video", + "videoId": "LDkoqb_jN3Y" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "LDkoqb_jN3Y", + "videoPublishedAt": "2025-06-04T04:00:23Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "FxwfQ0Qh-z8yzoC7MKQOn8uUACA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmJjaHktdXhGUkRr", + "snippet": { + "publishedAt": "2025-05-27T16:01:03Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "warmup REPO tonight! preparing for the crazy mod stream | w/ @luminere_flash @thatfewpeople", + "description": "Casual REPO gameplay\n\nGuests: \n\n[https://twitch.tv/luminere_flash](https://twitch.tv/luminere_flash) \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison)\n\nStream on May 23, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/bchy-uxFRDk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/bchy-uxFRDk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/bchy-uxFRDk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/bchy-uxFRDk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/bchy-uxFRDk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 167, + "resourceId": { + "kind": "youtube#video", + "videoId": "bchy-uxFRDk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "bchy-uxFRDk", + "videoPublishedAt": "2025-05-27T16:01:03Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "l3WqLWrhhLwCqZl5Z9iuSzVfy0w", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjY1MXBXTVVyaDVV", + "snippet": { + "publishedAt": "2025-05-23T10:00:11Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "More Roading! More Building! More Sand and Construction! | w/ @scarofman", + "description": "Second time playing Roadcraft \n\nMap 1 - Aftermath\n\nGuest: \n\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman)\n\nStream on May 21, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/651pWMUrh5U/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/651pWMUrh5U/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/651pWMUrh5U/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/651pWMUrh5U/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/651pWMUrh5U/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 168, + "resourceId": { + "kind": "youtube#video", + "videoId": "651pWMUrh5U" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "651pWMUrh5U", + "videoPublishedAt": "2025-05-23T10:00:11Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "7q0c_sN0UY_nLAP9wv-mmN7n21I", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjNOQzBlRndxQjFB", + "snippet": { + "publishedAt": "2025-05-22T16:00:12Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Building Roads and stuff! ROADCRAFT just released!!!! HYPE!!! | w/ @scarofman", + "description": "First time playing Roadcraft! Release Day! \n\nPlaying the tutorial and first map\n\nGuest: \n\nhttps://twitch.tv/scarofman\n\nStream on May 20, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/3NC0eFwqB1A/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/3NC0eFwqB1A/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/3NC0eFwqB1A/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/3NC0eFwqB1A/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/3NC0eFwqB1A/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 169, + "resourceId": { + "kind": "youtube#video", + "videoId": "3NC0eFwqB1A" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "3NC0eFwqB1A", + "videoPublishedAt": "2025-05-22T16:00:12Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "iezGUotFhNFvONEnOK02vfSFA-k", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjFZSThoQ3RVSUVv", + "snippet": { + "publishedAt": "2025-05-22T15:00:02Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Be Ready! or not. gonna shoot some suspects and hopefully get good scores! | w/ @kyuvulpes", + "description": "S-Tier Runs in Ready or Not \n\nMaps: \n\n3 Letter Triad (fail) \n\nTwisted Nerve (fail) \n\n...\n\nGuest: \n\n[https://twitch.tv/kyuvulpes](https://twitch.tv/kyuvulpes)\n\nStream on May 18, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/1YI8hCtUIEo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/1YI8hCtUIEo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/1YI8hCtUIEo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/1YI8hCtUIEo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/1YI8hCtUIEo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 170, + "resourceId": { + "kind": "youtube#video", + "videoId": "1YI8hCtUIEo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "1YI8hCtUIEo", + "videoPublishedAt": "2025-05-22T15:00:02Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "NZV-6PtSRBFoC4NQAc_ROYrZU-A", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnhfMXpzTl8wT0pR", + "snippet": { + "publishedAt": "2025-05-22T12:01:48Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Portal 1 Camera Shy Achievement and look at Challenges", + "description": "All Cameras in the \"Camera Shy\" Achievement\nLooking at the Challenges and figuring out which ones have not been done yet\n\nStream on May 17, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/x_1zsN_0OJQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/x_1zsN_0OJQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/x_1zsN_0OJQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/x_1zsN_0OJQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/x_1zsN_0OJQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 171, + "resourceId": { + "kind": "youtube#video", + "videoId": "x_1zsN_0OJQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "x_1zsN_0OJQ", + "videoPublishedAt": "2025-05-22T12:01:48Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "2fHq4fA89XMmgC7ORjVMD_vU7-w", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlpJVTFxMU9WaEpn", + "snippet": { + "publishedAt": "2025-05-22T11:00:52Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Time to suffer!!!! thanks @axoloaded (send help)", + "description": "Trying to progress in \"Getting Over It with Bennett Foddy\"\n\nStream on May 17, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/ZIU1q1OVhJg/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/ZIU1q1OVhJg/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/ZIU1q1OVhJg/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/ZIU1q1OVhJg/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/ZIU1q1OVhJg/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 172, + "resourceId": { + "kind": "youtube#video", + "videoId": "ZIU1q1OVhJg" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "ZIU1q1OVhJg", + "videoPublishedAt": "2025-05-22T11:00:52Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "CJkgNFB_QMU69RIrOSOWLRjkE8k", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkN4eUV6bndEZDEw", + "snippet": { + "publishedAt": "2025-05-21T19:00:25Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "RRRREEEEEPPPPPOOOOOOO | w/ @luminere_flash @stars_vr @thatfewpeople @annameison", + "description": "Big Lobby REPO! \n\nFirst time for @luminere_flash\n\nGuests: \n\n[https://twitch.tv/luminere_flash](https://twitch.tv/luminere_flash) \n\n[https://twitch.tv/stars_vr](https://twitch.tv/stars_vr) \n\n[https://twitch.tv/thatfewpeople](https://twitch.tv/thatfewpeople) \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison)\n\nStream on May 16, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/CxyEznwDd10/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/CxyEznwDd10/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/CxyEznwDd10/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/CxyEznwDd10/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/CxyEznwDd10/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 173, + "resourceId": { + "kind": "youtube#video", + "videoId": "CxyEznwDd10" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "CxyEznwDd10", + "videoPublishedAt": "2025-05-21T19:00:25Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "RazmW-CQtp3JnYX5SWLq8BMm300", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLklsejNjOFlBVmFz", + "snippet": { + "publishedAt": "2025-05-21T15:00:35Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Going into the Borderlands | w/ @luminere_flash", + "description": "Side-Questing in Borderlands 2\n\nGuest: \n\n[https://twitch.tv/luminere_flash](https://twitch.tv/luminere_flash)\n\nStream on May 14, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Ilz3c8YAVas/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Ilz3c8YAVas/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Ilz3c8YAVas/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Ilz3c8YAVas/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Ilz3c8YAVas/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 174, + "resourceId": { + "kind": "youtube#video", + "videoId": "Ilz3c8YAVas" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Ilz3c8YAVas", + "videoPublishedAt": "2025-05-21T15:00:35Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "vkFw08tVldKZBzKcP5eYefGFWl0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlppQzlNd2FjWC13", + "snippet": { + "publishedAt": "2025-05-21T13:00:58Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Horror house cleaning | w/ @annameison @ramskelly", + "description": "Waiting for friends to join, then doing the "House of horror" map\n\nStream on May 9, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/ZiC9MwacX-w/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/ZiC9MwacX-w/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/ZiC9MwacX-w/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/ZiC9MwacX-w/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/ZiC9MwacX-w/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 175, + "resourceId": { + "kind": "youtube#video", + "videoId": "ZiC9MwacX-w" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "ZiC9MwacX-w", + "videoPublishedAt": "2025-05-21T13:00:58Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "BAYUbv3_V9rHpX7TBVoGCF3ZcKE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmV1MDZFMjB2cS1J", + "snippet": { + "publishedAt": "2025-05-21T06:56:19Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Making the Borderlands unsafe again | w/ @luminere_flash", + "description": "Casual strolling through Borderlands two and doing side missions\n\nGuest: \n\n[https://twitch.tv/luminere_flash](https://twitch.tv/luminere_flash)\n\nStream on May 7, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/eu06E20vq-I/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/eu06E20vq-I/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/eu06E20vq-I/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/eu06E20vq-I/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/eu06E20vq-I/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 176, + "resourceId": { + "kind": "youtube#video", + "videoId": "eu06E20vq-I" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "eu06E20vq-I", + "videoPublishedAt": "2025-05-21T06:56:19Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "6URHUV7_8lds4hOc-jMOgfjWPB4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmUydEFkWUhTUW1V", + "snippet": { + "publishedAt": "2025-05-07T18:02:46Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Beta Reposession! testing the new mechanics, map and having fun! w/ @annameison @captain_pesky @dr_s", + "description": "REPO Beta Gameplay \n\n5 people casual runs\n\nGuests: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) @annameison \n\n[https://twitch.tv/captain_pesky](https://twitch.tv/captain_pesky) \n\n[https://twitch.tv/fayne_va](https://twitch.tv/fayne_va) \n\n[https://twitch.tv/dr_silkmoth](https://twitch.tv/dr_silkmoth)\n\nStream on May 06, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/e2tAdYHSQmU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/e2tAdYHSQmU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/e2tAdYHSQmU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/e2tAdYHSQmU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/e2tAdYHSQmU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 177, + "resourceId": { + "kind": "youtube#video", + "videoId": "e2tAdYHSQmU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "e2tAdYHSQmU", + "videoPublishedAt": "2025-05-07T18:02:46Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "gBq9-_501N3K-KVScpQxj43UBfg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkxlYjJSZUpXM0Fr", + "snippet": { + "publishedAt": "2025-05-03T10:00:27Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Its Cleaning Time! Viscera Cleanup with @ramskelly", + "description": "Viscera Cleanup full clean on Levels Unrefinery and Caduceus 100%\n\nGuests: \n\n[https://twitch.tv/ramskelly](https://twitch.tv/ramskelly) \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison)\n\nStream on April 25, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Leb2ReJW3Ak/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Leb2ReJW3Ak/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Leb2ReJW3Ak/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Leb2ReJW3Ak/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Leb2ReJW3Ak/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 178, + "resourceId": { + "kind": "youtube#video", + "videoId": "Leb2ReJW3Ak" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Leb2ReJW3Ak", + "videoPublishedAt": "2025-05-03T10:00:27Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "oK_A6KLi0vIXszwSvc7ApqvDPtI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkllN0laWDd1NVdN", + "snippet": { + "publishedAt": "2025-04-26T10:00:35Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "We border the lands! or something... | w/ @luminere_flash", + "description": "Borderlands 2 with luminere_flash\n\nGuest: \n\n[https://twitch.tv/luminere_flash](https://twitch.tv/luminere_flash)\n\nStream on April 23, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Ie7IZX7u5WM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Ie7IZX7u5WM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Ie7IZX7u5WM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Ie7IZX7u5WM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Ie7IZX7u5WM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 179, + "resourceId": { + "kind": "youtube#video", + "videoId": "Ie7IZX7u5WM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Ie7IZX7u5WM", + "videoPublishedAt": "2025-04-26T10:00:35Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "PT-ID2WZa6Lppew7a8xPvXCjMXg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjdVUTRnQk01Smpj", + "snippet": { + "publishedAt": "2025-04-22T16:00:16Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Late to the cleaning, but still here. Viscera Cleanup Detail with friends", + "description": "Full Clean on Waste Disposal and Core \n\nGuests: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) (@annameison )\n\n[https://twitch.tv/axoloaded](https://twitch.tv/axoloaded) (@Axoloaded )\n\n[https://twitch.tv/ramskelly](https://twitch.tv/ramskelly) (@RamSkelly )\n\nStream on April 21, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/7UQ4gBM5Jjc/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/7UQ4gBM5Jjc/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/7UQ4gBM5Jjc/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/7UQ4gBM5Jjc/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/7UQ4gBM5Jjc/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 180, + "resourceId": { + "kind": "youtube#video", + "videoId": "7UQ4gBM5Jjc" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "7UQ4gBM5Jjc", + "videoPublishedAt": "2025-04-22T16:00:16Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "P-jtcOtMec-BQw_TPk3kQxcXHvw", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnU5WmF0bHlTQUc0", + "snippet": { + "publishedAt": "2025-04-22T15:01:31Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Are you Ready kids? Ready or Not with @MoosemanEX @Jarothorn @annameison @Axoloaded", + "description": "Ready or Not \nCasual gaming, then S-Tier on Leviathan and trying to S-Tier on 3 Letter Triad\n\nGuests: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) (@annameison )\n\n[https://twitch.tv/jarothorn](https://twitch.tv/jarothorn) (@Jarothorn )\n\n[https://twitch.tv/axoloaded](https://twitch.tv/axoloaded) (@Axoloaded )\n\n[https://twitch.tv/moosemanex](https://twitch.tv/moosemanex) (@MoosemanEX )\n\nStream on April 21, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/u9ZatlySAG4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/u9ZatlySAG4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/u9ZatlySAG4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/u9ZatlySAG4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/u9ZatlySAG4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 181, + "resourceId": { + "kind": "youtube#video", + "videoId": "u9ZatlySAG4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "u9ZatlySAG4", + "videoPublishedAt": "2025-04-22T15:01:31Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "rM8alnDQiFumOPlnm2d5b5547EY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlE0TGtaaTNjZDdN", + "snippet": { + "publishedAt": "2025-04-22T14:00:40Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Streamer Trucking Co - driving through mud in | w/ @Jarothorn", + "description": "Snowrunner time! Trucking around \n\nSadly having problems, so not that much snowrunner this time\n\nGuests: \n[https://twitch.tv/jarothorn](https://twitch.tv/jarothorn) (@Jarothorn )\n\nStream on April 21, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Q4LkZi3cd7M/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Q4LkZi3cd7M/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Q4LkZi3cd7M/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Q4LkZi3cd7M/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Q4LkZi3cd7M/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 182, + "resourceId": { + "kind": "youtube#video", + "videoId": "Q4LkZi3cd7M" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Q4LkZi3cd7M", + "videoPublishedAt": "2025-04-22T14:00:40Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "z0hFcM-vYP4tN6xpoT3RrRaW0VM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjB3LTd1eGs0NW0w", + "snippet": { + "publishedAt": "2025-04-20T16:00:01Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Part 3 of the Big Lethal Company Collab! | w/ @stars_vr and friends", + "description": "Chaotic Lethal Company Collab with friends\n\nGuests: \n\n[https://twitch.tv/stars_vr](https://twitch.tv/stars_vr) (@starsgaming )\n\n[https://twitch.tv/flowaphenyx017](https://twitch.tv/flowaphenyx017) (@FlowaPhenyx017 )\n\n[https://twitch.tv/zuror](https://twitch.tv/zuror) (@Zuror )\n\n[https://twitch.tv/goobystrm](https://twitch.tv/goobystrm) (@Goobystrm )\n\n[https://twitch.tv/electrode13_5](https://twitch.tv/electrode13_5) \n\n[https://twitch.tv/masutifu_](https://twitch.tv/masutifu_) (@masutifu )\n\n[https://twitch.tv/kyralycaonwolf](https://twitch.tv/kyralycaonwolf)\n\nStream on April 19, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/0w-7uxk45m0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/0w-7uxk45m0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/0w-7uxk45m0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/0w-7uxk45m0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/0w-7uxk45m0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 183, + "resourceId": { + "kind": "youtube#video", + "videoId": "0w-7uxk45m0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "0w-7uxk45m0", + "videoPublishedAt": "2025-04-20T16:00:01Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "u740BLEqh5TZ5e7IMbo54L-iAR8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjRLOHVFeEJvSHNv", + "snippet": { + "publishedAt": "2025-04-19T16:00:05Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Surprise REPO Stream! w/ @annameison @masutifu_ @stars_vr", + "description": "Surprise Repo Stream. A lot of chaos, getting stuff together\n\nGuests: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\n[https://twitch.tv/masutifu_](https://twitch.tv/masutifu_) \n\n[https://twitch.tv/stars_vr](https://twitch.tv/stars_vr)\n\nStream on April 18, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/4K8uExBoHso/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/4K8uExBoHso/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/4K8uExBoHso/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/4K8uExBoHso/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/4K8uExBoHso/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 184, + "resourceId": { + "kind": "youtube#video", + "videoId": "4K8uExBoHso" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "4K8uExBoHso", + "videoPublishedAt": "2025-04-19T16:00:05Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Un78Un6MxtTo1cSXDoL1CjpkqAo", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLm16T0dWbnZxYWFR", + "snippet": { + "publishedAt": "2025-04-17T16:01:13Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "I am sure the Boderlands will be fun! | w/ @luminere_flash", + "description": "Borderlands 2 time with lumi. Progressing forwards and fighting Wilhelm\n\nGuest: \n\n[https://twitch.tv/luminere_flash](https://twitch.tv/luminere_flash) (@Luminere_flash )\n\nStream on April 16, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/mzOGVnvqaaQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/mzOGVnvqaaQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/mzOGVnvqaaQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/mzOGVnvqaaQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/mzOGVnvqaaQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 185, + "resourceId": { + "kind": "youtube#video", + "videoId": "mzOGVnvqaaQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "mzOGVnvqaaQ", + "videoPublishedAt": "2025-04-17T16:01:13Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "10JKG0E-yJzqrSLSdvdQ8q-TBng", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnRkaGVYOXZlOWw4", + "snippet": { + "publishedAt": "2025-04-14T16:01:23Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Trucking is what we want! going to play in the mud with @jarothorn", + "description": "Snowrunner with Aussis! unmodded base game\n\nGuest: \n\n[https://twitch.tv/jarothorn](https://twitch.tv/jarothorn) (@Jarothorn )\n\nStream on April 13, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/tdheX9ve9l8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/tdheX9ve9l8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/tdheX9ve9l8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/tdheX9ve9l8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/tdheX9ve9l8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 186, + "resourceId": { + "kind": "youtube#video", + "videoId": "tdheX9ve9l8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "tdheX9ve9l8", + "videoPublishedAt": "2025-04-14T16:01:23Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "pGCcecmaePIPB2ozv4Dhs7UXyDA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk1OcFB1MElzcnZj", + "snippet": { + "publishedAt": "2025-04-14T15:30:18Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Placid Plastic Duck Simulator - waiting for the Crypto Duck to finish", + "description": "Just waiting for the Crypto Duck to finish before we start stream\n\nStream on April 13, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/MNpPu0Isrvc/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/MNpPu0Isrvc/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/MNpPu0Isrvc/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/MNpPu0Isrvc/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/MNpPu0Isrvc/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 187, + "resourceId": { + "kind": "youtube#video", + "videoId": "MNpPu0Isrvc" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "MNpPu0Isrvc", + "videoPublishedAt": "2025-04-14T15:30:18Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "2wpMf-zM2IsK8pWPnXm2-VdcBkc", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkZsVGJ1NGpuVG9V", + "snippet": { + "publishedAt": "2025-04-14T10:00:32Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Modded Lethal Company with friends", + "description": "Chaotic big Collab Stream with many friends \n\nModded Lethal Company\n\nGuests: \n\n[https://twitch.tv/stars_vr](https://twitch.tv/stars_vr) (@starsgaming )\n\n[https://twitch.tv/flowaphenyx017](https://twitch.tv/flowaphenyx017) (@FlowaPhenyx017 )\n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) (@annameison )\n\n[https://twitch.tv/zuror](https://twitch.tv/zuror) (@Zuror )\n\n[https://twitch.tv/goobystrm](https://twitch.tv/goobystrm) (@Goobystrm )\n\n[https://twitch.tv/electrode13_5](https://twitch.tv/electrode13_5)\n\n[https://twitch.tv/masutifu_](https://twitch.tv/masutifu_) \nStream on April 12, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/FlTbu4jnToU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/FlTbu4jnToU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/FlTbu4jnToU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/FlTbu4jnToU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/FlTbu4jnToU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 188, + "resourceId": { + "kind": "youtube#video", + "videoId": "FlTbu4jnToU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "FlTbu4jnToU", + "videoPublishedAt": "2025-04-14T10:00:32Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "M6LybvV6dIvRj_UvpFrA-MEIWnk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkE1OG5wODlTVGZR", + "snippet": { + "publishedAt": "2025-04-13T16:00:11Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Messy again? Viscera cleanup with @ramskelly", + "description": "Cleaning in Viscera Cleanup Detail with friends \n\nGrind to 100% the game\n\nGuests: \n\n[https://twitch.tv/ramskelly](https://twitch.tv/ramskelly) (@RamSkelly )\n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) (@annameison )\n\nStream on April 11, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/A58np89STfQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/A58np89STfQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/A58np89STfQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/A58np89STfQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/A58np89STfQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 189, + "resourceId": { + "kind": "youtube#video", + "videoId": "A58np89STfQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "A58np89STfQ", + "videoPublishedAt": "2025-04-13T16:00:11Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "6_NyOu3PYwqgspO2TXiLMXbeiuo", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjVwWGcxaUNaNno4", + "snippet": { + "publishedAt": "2025-04-10T16:00:08Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "into the borderlands we go! | w/ @luminere_flash", + "description": "Borderlands 2 gameplay - continuing the main mission after some technical difficulties\n\nGuest: \n\n[https://twitch.tv/luminere_flash](https://twitch.tv/luminere_flash) (@Luminere_flash )\n\nStream on April 10, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/5pXg1iCZ6z8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/5pXg1iCZ6z8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/5pXg1iCZ6z8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/5pXg1iCZ6z8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/5pXg1iCZ6z8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 190, + "resourceId": { + "kind": "youtube#video", + "videoId": "5pXg1iCZ6z8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "5pXg1iCZ6z8", + "videoPublishedAt": "2025-04-10T16:00:08Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "PonU7FjYnJpeSmulAD445yIDT6c", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkdObVh2ZmhlTGI0", + "snippet": { + "publishedAt": "2025-04-08T10:00:31Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "its REPO Time! (almost) 9 hours of REPO Stream!", + "description": "Playing some modded REPO (with a few funny (or stupid?) bugs occuring\n\nGuests: \n[https://twitch.tv/scarofman](https://twitch.tv/scarofman) \n[https://twitch.tv/jarothorn](https://twitch.tv/jarothorn) \n[https://twitch.tv/moosemanex](https://twitch.tv/moosemanex) \n[https://twitch.tv/axoloaded](https://twitch.tv/axoloaded) \n[https://twitch.tv/stars_vr](https://twitch.tv/stars_vr)\n\nStream on April 6, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/GNmXvfheLb4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/GNmXvfheLb4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/GNmXvfheLb4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/GNmXvfheLb4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/GNmXvfheLb4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 191, + "resourceId": { + "kind": "youtube#video", + "videoId": "GNmXvfheLb4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "GNmXvfheLb4", + "videoPublishedAt": "2025-04-08T10:00:31Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "6z8wi4EuJXTrHZNaoVhBqd9itl8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLndTUk9LbmQ3OVVv", + "snippet": { + "publishedAt": "2025-04-07T16:01:14Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Snowrunner stream - problems with friends connecting, so not much gameplay", + "description": "Trying to play Snowrunner. Sadly we had a lot of problems.\n\nGuests: \n\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman) (@scarofman1426 )\n[https://twitch.tv/jarothorn](https://twitch.tv/jarothorn) (@Jarothorn )\n\nStream on April 6, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/wSROKnd79Uo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/wSROKnd79Uo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/wSROKnd79Uo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/wSROKnd79Uo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/wSROKnd79Uo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 192, + "resourceId": { + "kind": "youtube#video", + "videoId": "wSROKnd79Uo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "wSROKnd79Uo", + "videoPublishedAt": "2025-04-07T16:01:14Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "b5aPGsCzqLcDWIMt_9126oQldOg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlJhVDJYWHhtVzM4", + "snippet": { + "publishedAt": "2025-04-06T16:00:26Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "CraftMine. Lets defeate some mines", + "description": "Some casual playing in CraftMine, the Minecraft April Fools Update 2025\n\nStream on April 05, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/RaT2XXxmW38/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/RaT2XXxmW38/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/RaT2XXxmW38/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/RaT2XXxmW38/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/RaT2XXxmW38/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 193, + "resourceId": { + "kind": "youtube#video", + "videoId": "RaT2XXxmW38" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "RaT2XXxmW38", + "videoPublishedAt": "2025-04-06T16:00:26Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "W_cA23afmCwUOqqH9pri4mNnXlM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkl6OGUwRU9FVV9j", + "snippet": { + "publishedAt": "2025-04-06T10:00:31Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Collab Time! Modded Lethal Company with MANY PEOPLE!", + "description": "Modded Lethal Company with a bunch of friends!\n\nGuests: \n\n[https://twitch.tv/stars_vr](https://twitch.tv/stars_vr) (@starsgaming)\n[https://twitch.tv/flowaphenyx017](https://twitch.tv/flowaphenyx017) (@FlowaPhenyx017)\n[https://twitch.tv/annameison](https://twitch.tv/annameison) (@annameison)\n[https://twitch.tv/zuror](https://twitch.tv/zuror) (@Zuror)\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman) (@scarofman1426)\n[https://twitch.tv/goobystrm](https://twitch.tv/goobystrm) (@Goobystrm)\n[https://twitch.tv/electrode13_5](https://twitch.tv/electrode13_5) \n[https://twitch.tv/masutifu_](https://twitch.tv/masutifu_) (@masutifu)\nStream on April 4, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Iz8e0EOEU_c/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Iz8e0EOEU_c/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Iz8e0EOEU_c/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Iz8e0EOEU_c/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Iz8e0EOEU_c/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 194, + "resourceId": { + "kind": "youtube#video", + "videoId": "Iz8e0EOEU_c" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Iz8e0EOEU_c", + "videoPublishedAt": "2025-04-06T10:00:31Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Oy_YsZVoKhp-nP5KDcINBB3T5zs", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjRldnhackhCdVVj", + "snippet": { + "publishedAt": "2025-04-06T04:00:07Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Everything is messy again... lets clean it! | w/ @annameison @ramskelly", + "description": "On the hunt for the perfect clean in Viscera Cleanup Detail.\n\nGuests: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\n[https://twitch.tv/ramskelly](https://twitch.tv/ramskelly)\n\nStream on April 2, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/4evxZrHBuUc/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/4evxZrHBuUc/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/4evxZrHBuUc/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/4evxZrHBuUc/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/4evxZrHBuUc/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 195, + "resourceId": { + "kind": "youtube#video", + "videoId": "4evxZrHBuUc" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "4evxZrHBuUc", + "videoPublishedAt": "2025-04-06T04:00:07Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Ld2yK5mZfwiZ_CHd1HDsZXDV-UI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlI4NlFnd0lWdEE0", + "snippet": { + "publishedAt": "2025-04-05T16:00:35Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "April Fools CraftMine Update! Short stream to explore!", + "description": "Playing the Minecraft April Fools Update (CraftMine)\n\nStream on April 1, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/R86QgwIVtA4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/R86QgwIVtA4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/R86QgwIVtA4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/R86QgwIVtA4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/R86QgwIVtA4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 196, + "resourceId": { + "kind": "youtube#video", + "videoId": "R86QgwIVtA4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "R86QgwIVtA4", + "videoPublishedAt": "2025-04-05T16:00:35Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "gB_8aLZtZnBkxO8IEbEkKcNCpzA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnc3NldOcWdRT1cw", + "snippet": { + "publishedAt": "2025-04-05T10:01:14Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "I am kinda sick ._. | Modded REPO | I WANT ALL THE VALUABLES!!!! | w/ @stars_vr @annameison", + "description": "Modded REPO with friends.\n\nI still have a cold, so sorry for the stuffed nose noises.\n\nGuests:\nhttps://twitch.tv/stars_vr (@starsgaming)\nhttps://twitch.tv/annameison (@annameison)\n\nBroadcasted live on Twitch -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/w76WNqgQOW0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/w76WNqgQOW0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/w76WNqgQOW0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/w76WNqgQOW0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/w76WNqgQOW0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 197, + "resourceId": { + "kind": "youtube#video", + "videoId": "w76WNqgQOW0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "w76WNqgQOW0", + "videoPublishedAt": "2025-04-05T10:01:14Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "nK6XD3RZOtcmd1dJOCw8gHpycW0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmV0Y1QzRjJzeFFV", + "snippet": { + "publishedAt": "2025-04-05T04:00:38Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "I WANT ALL THE VALUABLES!!!! Hopping on @captain_pesky s pirate-ship| !dc !commands !meow !music", + "description": "REPO collab with Captain_Pesky\nFirst half an hour is just yapping about everything while I wait for Captain Pesky to go live\n\nGuest: \n[https://twitch.tv/captain_pesky](https://twitch.tv/captain_pesky)\n\nBroadcasted live on Twitch -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/etcT3F2sxQU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/etcT3F2sxQU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/etcT3F2sxQU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/etcT3F2sxQU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/etcT3F2sxQU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 198, + "resourceId": { + "kind": "youtube#video", + "videoId": "etcT3F2sxQU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "etcT3F2sxQU", + "videoPublishedAt": "2025-04-05T04:00:38Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "oAubC53CfgWRJ977rA4Hx64A1dw", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmhqZDBLLWd4SGtv", + "snippet": { + "publishedAt": "2025-04-04T16:01:06Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "who left this mess? Cleaning with @ramskelly and @annameison", + "description": "Casual Viscera Cleanup Gameplay\n\nGuests \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) (@annameison )\n\n[https://twitch.tv/ramskelly](https://twitch.tv/ramskelly) (@RamSkelly )\n\nStream on March 26, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/hjd0K-gxHko/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/hjd0K-gxHko/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/hjd0K-gxHko/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/hjd0K-gxHko/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/hjd0K-gxHko/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 199, + "resourceId": { + "kind": "youtube#video", + "videoId": "hjd0K-gxHko" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "hjd0K-gxHko", + "videoPublishedAt": "2025-04-04T16:01:06Z" + }, + "status": { + "privacyStatus": "public" + } + } + ], + "pageInfo": { + "totalResults": 405, + "resultsPerPage": 50 + } + }, + { + "kind": "youtube#playlistItemListResponse", + "etag": "G9QrFIdr6AbBpapVGO0XVTa-QSY", + "nextPageToken": "EAAaB1BUOkNQb0I", + "prevPageToken": "EAEaB1BUOkNNZ0I", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "6Vl5mMIhfDzKMtxrQQ3sGKPFvyI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnc3NldOcWdRT1cw", + "snippet": { + "publishedAt": "2025-04-05T10:01:14Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "I am kinda sick ._. | Modded REPO | I WANT ALL THE VALUABLES!!!! | w/ @stars_vr @annameison", + "description": "Modded REPO with friends.\n\nI still have a cold, so sorry for the stuffed nose noises.\n\nGuests:\nhttps://twitch.tv/stars_vr (@starsgaming)\nhttps://twitch.tv/annameison (@annameison)\n\nBroadcasted live on Twitch -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/w76WNqgQOW0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/w76WNqgQOW0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/w76WNqgQOW0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/w76WNqgQOW0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/w76WNqgQOW0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 200, + "resourceId": { + "kind": "youtube#video", + "videoId": "w76WNqgQOW0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "w76WNqgQOW0", + "videoPublishedAt": "2025-04-05T10:01:14Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "SmgX8r144uTrFdRUulnddqBUkEI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmV0Y1QzRjJzeFFV", + "snippet": { + "publishedAt": "2025-04-05T04:00:38Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "I WANT ALL THE VALUABLES!!!! Hopping on @captain_pesky s pirate-ship| !dc !commands !meow !music", + "description": "REPO collab with Captain_Pesky\nFirst half an hour is just yapping about everything while I wait for Captain Pesky to go live\n\nGuest: \n[https://twitch.tv/captain_pesky](https://twitch.tv/captain_pesky)\n\nBroadcasted live on Twitch -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/etcT3F2sxQU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/etcT3F2sxQU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/etcT3F2sxQU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/etcT3F2sxQU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/etcT3F2sxQU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 201, + "resourceId": { + "kind": "youtube#video", + "videoId": "etcT3F2sxQU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "etcT3F2sxQU", + "videoPublishedAt": "2025-04-05T04:00:38Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "lQ3Yu2pHyaA1G4jGhNXmfLAyncg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmhqZDBLLWd4SGtv", + "snippet": { + "publishedAt": "2025-04-04T16:01:06Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "who left this mess? Cleaning with @ramskelly and @annameison", + "description": "Casual Viscera Cleanup Gameplay\n\nGuests \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) (@annameison )\n\n[https://twitch.tv/ramskelly](https://twitch.tv/ramskelly) (@RamSkelly )\n\nStream on March 26, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/hjd0K-gxHko/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/hjd0K-gxHko/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/hjd0K-gxHko/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/hjd0K-gxHko/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/hjd0K-gxHko/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 202, + "resourceId": { + "kind": "youtube#video", + "videoId": "hjd0K-gxHko" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "hjd0K-gxHko", + "videoPublishedAt": "2025-04-04T16:01:06Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "CedOrgVGSuV4UB3cThI5Z9sDSiQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlFwR3pWWFY5Wi04", + "snippet": { + "publishedAt": "2025-04-04T04:00:24Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Reacting to Minecraft live on stream", + "description": "Talking about and reacting to the last part of Minecraft Live\n\nStream on March 22, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/QpGzVXV9Z-8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/QpGzVXV9Z-8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/QpGzVXV9Z-8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/QpGzVXV9Z-8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/QpGzVXV9Z-8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 203, + "resourceId": { + "kind": "youtube#video", + "videoId": "QpGzVXV9Z-8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "QpGzVXV9Z-8", + "videoPublishedAt": "2025-04-04T04:00:24Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "H4f-aZfjCsxAND7PblY-1Jjoi3w", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlBxN3BUTnlSNC1j", + "snippet": { + "publishedAt": "2025-04-03T16:00:35Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "A little bit of Satisfactory. Brain had other plans", + "description": "Satisfactory Gameplay\n\nStream on March 22, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Pq7pTNyR4-c/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Pq7pTNyR4-c/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Pq7pTNyR4-c/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Pq7pTNyR4-c/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Pq7pTNyR4-c/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 204, + "resourceId": { + "kind": "youtube#video", + "videoId": "Pq7pTNyR4-c" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Pq7pTNyR4-c", + "videoPublishedAt": "2025-04-03T16:00:35Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "UE-T8q9lu8KshtOzXY1etr42Lzg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmg5eUI3cHc5eWVZ", + "snippet": { + "publishedAt": "2025-04-02T10:01:01Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "BABA AND DEVIL IS YOU - CHAT IS ____ - GAME IS PLAY", + "description": "Brain Breaking in BABA IS YOU\n\nStream on March 19, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/h9yB7pw9yeY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/h9yB7pw9yeY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/h9yB7pw9yeY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/h9yB7pw9yeY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/h9yB7pw9yeY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 205, + "resourceId": { + "kind": "youtube#video", + "videoId": "h9yB7pw9yeY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "h9yB7pw9yeY", + "videoPublishedAt": "2025-04-02T10:01:01Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "1a1PyIa6G37DTFKsrrkgX7TzLJ4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlZ3NnhlVHkxUTU0", + "snippet": { + "publishedAt": "2025-04-02T04:00:31Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "The Company is Lethal once again! First time for FlowaPhenyx017", + "description": "Lethal Company Base game with a first-timer!\n\nGuests:\nhttps://twitch.tv/stars_vr (@starsgaming)\nhttps://twitch.tv/masutifu_ (@masutifu)\nhttps://twitch.tv/flowaphenyx017 (@FlowaPhenyx017)", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Vw6xeTy1Q54/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Vw6xeTy1Q54/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Vw6xeTy1Q54/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Vw6xeTy1Q54/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Vw6xeTy1Q54/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 206, + "resourceId": { + "kind": "youtube#video", + "videoId": "Vw6xeTy1Q54" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Vw6xeTy1Q54", + "videoPublishedAt": "2025-04-02T04:00:31Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "IzEjqWPA0Tw8f1zcfSSO1VcgBbg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjRZTndiVXdpTFBZ", + "snippet": { + "publishedAt": "2025-04-01T16:00:05Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Yapping and figuring stuff out while watching ducks in Placid Plastic Duck Simulator", + "description": "Yapping and figuring stuff out while watching ducks in Placid Plastic Duck Simulator\n\nStream on March 16, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/4YNwbUwiLPY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/4YNwbUwiLPY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/4YNwbUwiLPY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/4YNwbUwiLPY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/4YNwbUwiLPY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 207, + "resourceId": { + "kind": "youtube#video", + "videoId": "4YNwbUwiLPY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "4YNwbUwiLPY", + "videoPublishedAt": "2025-04-01T16:00:05Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Uo8hoRLCFdBwgMz7d8YjE85KoT8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjF0Q2tKRi1SY0kw", + "snippet": { + "publishedAt": "2025-04-01T10:00:02Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Ready or Not! Here we come!!! Trying to S-Tier", + "description": "Road to all S-Tier in Ready or Not\n\nGuest: https://twitch.tv/annameison (@annameison)\n\nStream on March 16, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/1tCkJF-RcI0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/1tCkJF-RcI0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/1tCkJF-RcI0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/1tCkJF-RcI0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/1tCkJF-RcI0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 208, + "resourceId": { + "kind": "youtube#video", + "videoId": "1tCkJF-RcI0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "1tCkJF-RcI0", + "videoPublishedAt": "2025-04-01T10:00:02Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "BtxmXlnjlRVMYDUGK0l0PmsCpRc", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnVZaWpBWW54WHU0", + "snippet": { + "publishedAt": "2025-04-01T04:00:49Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Repossessing everything! Friends included! - REPO with Frineds", + "description": "R.E.P.O. with friends! \n\nGuests:\nhttps://twitch.tv/annameison (@annameison)\nhttps://twitch.tv/jarothorn (@Jarothorn)\nhttps://twitch.tv/moosemanex (@MoosemanEX)\nhttps://twitch.tv/axoloaded (@Axoloaded)\n\nStream on March 16, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/uYijAYnxXu4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/uYijAYnxXu4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/uYijAYnxXu4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/uYijAYnxXu4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/uYijAYnxXu4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 209, + "resourceId": { + "kind": "youtube#video", + "videoId": "uYijAYnxXu4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "uYijAYnxXu4", + "videoPublishedAt": "2025-04-01T04:00:49Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "vBLzWe6bQsxeLqNrRihYD64fZYE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjhDMUJHLVZrcUVR", + "snippet": { + "publishedAt": "2025-03-26T08:04:53Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "The wastelands are calling! The Forever Winter with @evangelist___", + "description": "The Forever Winter gameplay\n\nNew Map, new gun handling, new Watermechanic\n\nGuest: \n\n[https://twitch.tv/evangelist___](https://twitch.tv/evangelist___)\n\nStream on March 14, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/8C1BG-VkqEQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/8C1BG-VkqEQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/8C1BG-VkqEQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/8C1BG-VkqEQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/8C1BG-VkqEQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 210, + "resourceId": { + "kind": "youtube#video", + "videoId": "8C1BG-VkqEQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "8C1BG-VkqEQ", + "videoPublishedAt": "2025-03-26T08:04:53Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "8U-fFU1aMvdab-ZWKWh23zroVwU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlBlN0hBdmVHSXpN", + "snippet": { + "publishedAt": "2025-03-24T19:28:10Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Satisfactory time! STEEEEEL is what we need!", + "description": "Casual Satisfactory gameplay\n\nStream on March 12, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Pe7HAveGIzM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Pe7HAveGIzM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Pe7HAveGIzM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Pe7HAveGIzM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Pe7HAveGIzM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 211, + "resourceId": { + "kind": "youtube#video", + "videoId": "Pe7HAveGIzM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Pe7HAveGIzM", + "videoPublishedAt": "2025-03-24T19:28:10Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "grQ1VqznirQdWkP_kaP0b4ubB6k", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLm0xNjRFUk12RUJr", + "snippet": { + "publishedAt": "2025-03-21T17:01:02Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "The wilds are out there! Outer wilds with @scarofman and @annameison", + "description": "Outer Wilds First Playthrough\n\nStream on March 10, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/m164ERMvEBk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/m164ERMvEBk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/m164ERMvEBk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/m164ERMvEBk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/m164ERMvEBk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 212, + "resourceId": { + "kind": "youtube#video", + "videoId": "m164ERMvEBk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "m164ERMvEBk", + "videoPublishedAt": "2025-03-21T17:01:02Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "0-_VGwiH_ZyAyFVxbJP4THzGkFg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkJqU3BacGgxVXUw", + "snippet": { + "publishedAt": "2025-03-21T11:00:29Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Some chill Satisfactory - Its been too long", + "description": "Satisfactory gameplay\n\nStream on March 4, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/BjSpZph1Uu0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/BjSpZph1Uu0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/BjSpZph1Uu0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/BjSpZph1Uu0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/BjSpZph1Uu0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 213, + "resourceId": { + "kind": "youtube#video", + "videoId": "BjSpZph1Uu0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "BjSpZph1Uu0", + "videoPublishedAt": "2025-03-21T11:00:29Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "1kEiCRutjwLz2dKweRDuM6qsmQw", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLm9rVmpEU0hSQndv", + "snippet": { + "publishedAt": "2025-03-20T17:01:05Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "ITS PORTAL TIME!", + "description": "Finishing Portal Revolution\n\nStream on March 2, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/okVjDSHRBwo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/okVjDSHRBwo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/okVjDSHRBwo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/okVjDSHRBwo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/okVjDSHRBwo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 214, + "resourceId": { + "kind": "youtube#video", + "videoId": "okVjDSHRBwo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "okVjDSHRBwo", + "videoPublishedAt": "2025-03-20T17:01:05Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "8NAz41ennVXDeGJbbf00XxCpPsA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk84cTFRTng4ZURB", + "snippet": { + "publishedAt": "2025-03-20T11:00:25Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Into the borderlands we go | Borderlands 2 with @luminere_flash", + "description": "Borderlands 2 with Friends\n\nGuest: \n\n[https://twitch.tv/luminere_flash](https://twitch.tv/luminere_flash) (@Luminere_flash)\n\nStream on February 26, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/O8q1QNx8eDA/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/O8q1QNx8eDA/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/O8q1QNx8eDA/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/O8q1QNx8eDA/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/O8q1QNx8eDA/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 215, + "resourceId": { + "kind": "youtube#video", + "videoId": "O8q1QNx8eDA" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "O8q1QNx8eDA", + "videoPublishedAt": "2025-03-20T11:00:25Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "bWiQMtvxWnYMMup0mhS5M1jY_9U", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk12WHRwcDR5ekow", + "snippet": { + "publishedAt": "2025-03-19T17:00:24Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Ready or Not - On the Hunt for S-Tier on the oil rig | Affilliate anniversary stream", + "description": "Trying to S-Tier the oil-rig in Ready or Not\n\nGuest:\nhttps://twitch.tv/annameison (@annameison)\n\nStream on February 22, 2025", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/MvXtpp4yzJ0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/MvXtpp4yzJ0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/MvXtpp4yzJ0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/MvXtpp4yzJ0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/MvXtpp4yzJ0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 216, + "resourceId": { + "kind": "youtube#video", + "videoId": "MvXtpp4yzJ0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "MvXtpp4yzJ0", + "videoPublishedAt": "2025-03-19T17:00:24Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "4iBX_PExAP1LRwYP5uR5c9hoEqA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjI4Z0dRVHA2ZkhB", + "snippet": { + "publishedAt": "2025-03-19T11:00:03Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Trying to survive this freaky gameshow - 7 Minutes in Hell | Affilliate anniversary stream", + "description": "7 Minutes in Hell gameplay\n\nGuests:\nhttps://twitch.tv/axoloaded (@Axoloaded)\nhttps://twitch.tv/spicynoobles (@SpicyNoobles)\nhttps://twitch.tv/scarofman (@scarofman1426)\n\nStream on February 22, 2025 -- Watch live on https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/28gGQTp6fHA/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/28gGQTp6fHA/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/28gGQTp6fHA/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/28gGQTp6fHA/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/28gGQTp6fHA/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 217, + "resourceId": { + "kind": "youtube#video", + "videoId": "28gGQTp6fHA" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "28gGQTp6fHA", + "videoPublishedAt": "2025-03-19T11:00:03Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "ib5tFTDdlWmZS4UXcL-gzkY64pk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjlRN2dlWUdyamUw", + "snippet": { + "publishedAt": "2025-03-18T17:00:15Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Ready Or Not with friends | Affilliate anniversary stream", + "description": "Ready or Not Gameplay with friends\n\nGuests: \nhttps://twitch.tv/axoloaded (@Axoloaded)\nhttps://twitch.tv/jarothorn (@Jarothorn)\nhttps://twitch.tv/scarofman (@scarofman1426)\nhttps://twitch.tv/moosemanex (@MoosemanEX)\n\nStream on February 22, 2025 -- Watch live https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/9Q7geYGrje0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/9Q7geYGrje0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/9Q7geYGrje0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/9Q7geYGrje0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/9Q7geYGrje0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 218, + "resourceId": { + "kind": "youtube#video", + "videoId": "9Q7geYGrje0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "9Q7geYGrje0", + "videoPublishedAt": "2025-03-18T17:00:15Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "vwBlEKKyQXOGWodrlSmzg0WlrbQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnNFUTE0V1psR0FZ", + "snippet": { + "publishedAt": "2025-02-28T11:01:26Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "THE END IS NEVER THE END IS NEVER THE END IS NEVER THE END IS NEVER THE END IS NEVER THE END IS [...", + "description": "Chill Stanley Parable Gameplay\n\nStream on February 16, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/sEQ14WZlGAY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/sEQ14WZlGAY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/sEQ14WZlGAY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/sEQ14WZlGAY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/sEQ14WZlGAY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 219, + "resourceId": { + "kind": "youtube#video", + "videoId": "sEQ14WZlGAY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "sEQ14WZlGAY", + "videoPublishedAt": "2025-02-28T11:01:26Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "t4cX_DpnA3JVm1H_CE2rpJONYJk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlFBRm9jY3dpNFdr", + "snippet": { + "publishedAt": "2025-02-27T11:00:29Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "BRAINCELLS! UNITE AGAINST CRIME! | Ready or Not", + "description": "Ready or Not - Trying to get S Tier\n\nGuests: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) @annameison \n[https://twitch.tv/axoloaded](https://twitch.tv/axoloaded) @Axoloaded \n[https://twitch.tv/jarothorn](https://twitch.tv/jarothorn) @Jarothorn \n[https://twitch.tv/moosemanex](https://twitch.tv/moosemanex) @MoosemanEX \n[https://twitch.tv/kyuvulpes](https://twitch.tv/kyuvulpes) @KyuVulpes \n[https://twitch.tv/scarofman](https://twitch.tv/scarofman) @scarofman1426 \n\nStream on February 16, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/QAFoccwi4Wk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/QAFoccwi4Wk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/QAFoccwi4Wk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/QAFoccwi4Wk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/QAFoccwi4Wk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 220, + "resourceId": { + "kind": "youtube#video", + "videoId": "QAFoccwi4Wk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "QAFoccwi4Wk", + "videoPublishedAt": "2025-02-27T11:00:29Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "s5OmNONGD36wSsqpDH7CXhFsBag", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlZFTWl4TS0wNXZB", + "snippet": { + "publishedAt": "2025-02-26T11:00:50Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Fluff-Brains working for a lethal company | w/ @starsgaming @annameison", + "description": "Modded Lethal Company gameplay\n\nGuests: \n\n[https://twitch.tv/stars_vr](https://twitch.tv/stars_vr) @starsgaming \n[https://twitch.tv/annameison](https://twitch.tv/annameison) @annameison \n\nStream on February 14, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/VEMixM-05vA/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/VEMixM-05vA/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/VEMixM-05vA/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/VEMixM-05vA/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/VEMixM-05vA/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 221, + "resourceId": { + "kind": "youtube#video", + "videoId": "VEMixM-05vA" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "VEMixM-05vA", + "videoPublishedAt": "2025-02-26T11:00:50Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "jW1UKhbcqYCfW8MbZgrqdKkU1Yk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmI3cnRfdXNOQjNr", + "snippet": { + "publishedAt": "2025-02-25T11:00:47Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "OUTER WILDS!!! ONE MORE TIME!!! Lets figure stuff out | w/ @scarofman1426", + "description": "1st Playthrough of Outer Wilds\n\nGuest: \n\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman) @scarofman1426 \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) @annameison \n\nStream on February 12, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/b7rt_usNB3k/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/b7rt_usNB3k/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/b7rt_usNB3k/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/b7rt_usNB3k/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/b7rt_usNB3k/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 222, + "resourceId": { + "kind": "youtube#video", + "videoId": "b7rt_usNB3k" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "b7rt_usNB3k", + "videoPublishedAt": "2025-02-25T11:00:47Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "qZe5w_mLJxVoyUSV_SAxARYuNsk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlNBQmM5NXdMZnJ3", + "snippet": { + "publishedAt": "2025-02-24T17:00:35Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "VIVE LA REVOLUTION! Or something... First time Playing Portal Revolution", + "description": "First time playing "Portal Revolution"\n\nStream on February 07, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/SABc95wLfrw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/SABc95wLfrw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/SABc95wLfrw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/SABc95wLfrw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/SABc95wLfrw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 223, + "resourceId": { + "kind": "youtube#video", + "videoId": "SABc95wLfrw" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "SABc95wLfrw", + "videoPublishedAt": "2025-02-24T17:00:35Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Tg65hEtE5gyVgDjh1MFZ0pRYzTc", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk9ZalhoX3BXWGRF", + "snippet": { + "publishedAt": "2025-02-24T09:54:11Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Good choices? Me? I am not even sure if I can do \"not bad\" ones really", + "description": "First time playing \"Make good Choices\"\n\nStream on February 07, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/OYjXh_pWXdE/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/OYjXh_pWXdE/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/OYjXh_pWXdE/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/OYjXh_pWXdE/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/OYjXh_pWXdE/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 224, + "resourceId": { + "kind": "youtube#video", + "videoId": "OYjXh_pWXdE" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "OYjXh_pWXdE", + "videoPublishedAt": "2025-02-24T09:54:11Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "5i494LIoMMrNN7AnRBWiWfJIsuA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnFvalZ1dFlOOUZJ", + "snippet": { + "publishedAt": "2025-02-03T11:01:08Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "GET ME OUT OF HERE! Solving Chambers with @kyuvulpes @scarofman", + "description": "Casual brainrot stream of GTFO\n\nGuests: \n\n[https://twitch.tv/kyuvulpes](https://twitch.tv/kyuvulpes) \n\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman)\n\nStream on January 29, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/qojVutYN9FI/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/qojVutYN9FI/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/qojVutYN9FI/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/qojVutYN9FI/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/qojVutYN9FI/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 225, + "resourceId": { + "kind": "youtube#video", + "videoId": "qojVutYN9FI" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "qojVutYN9FI", + "videoPublishedAt": "2025-02-03T11:01:08Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "y9ZuHbVn2nFlc-SDlGCL3Y6A1KY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlNEZ0lyWjUwdjRR", + "snippet": { + "publishedAt": "2025-02-02T17:00:26Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "STOP RESISTING! We will probably just arrest you!", + "description": "Some Casual Ready or Not with Friends\n\nGuest: \nhttps://twitch.tv/annameison ( @annameison )\n\nStream on January 25, 2025", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/SDgIrZ50v4Q/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/SDgIrZ50v4Q/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/SDgIrZ50v4Q/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/SDgIrZ50v4Q/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/SDgIrZ50v4Q/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 226, + "resourceId": { + "kind": "youtube#video", + "videoId": "SDgIrZ50v4Q" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "SDgIrZ50v4Q", + "videoPublishedAt": "2025-02-02T17:00:26Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "xxRp7IfWNroDA-DWzinv3cqtUnM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmd2NXZLOU5nZ1dF", + "snippet": { + "publishedAt": "2025-02-02T11:00:45Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Space Buddies for the rescue! Figuring out the Outer Wilds", + "description": "First Time Experiences in Outer Wilds.\nContinuing the Play with friends.\n\nGuests:\nhttps://twitch.tv/annameison ( @annameison )\nhttps://twitch.tv/scarofman ( @scarofman1426 )\nhttps://twitch.tv/moosemanex ( @MoosemanEX )\n\nStream on January 25, 2025 -- watch live: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/gv5vK9NggWE/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/gv5vK9NggWE/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/gv5vK9NggWE/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/gv5vK9NggWE/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/gv5vK9NggWE/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 227, + "resourceId": { + "kind": "youtube#video", + "videoId": "gv5vK9NggWE" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "gv5vK9NggWE", + "videoPublishedAt": "2025-02-02T11:00:45Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "wH8eGSqRc29qPC1qoUirwW1WFJQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLndLaEFCRU5ZV2dV", + "snippet": { + "publishedAt": "2025-02-01T11:01:15Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Portaling around with @annameison", + "description": "Finishing the Portal 2 Multiplayer and doing some custom race maps\n\nGuest: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison)\n\nStream on January 24, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/wKhABENYWgU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/wKhABENYWgU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/wKhABENYWgU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/wKhABENYWgU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/wKhABENYWgU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 228, + "resourceId": { + "kind": "youtube#video", + "videoId": "wKhABENYWgU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "wKhABENYWgU", + "videoPublishedAt": "2025-02-01T11:01:15Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "7duoVZZiaVrvY9iQ4cJNwuiyeQ4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjdmRGswWm1yRTc0", + "snippet": { + "publishedAt": "2025-01-31T11:00:08Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "STOP RESISTING!!! - with @annameison and @evangelist___", + "description": "First time playing Ready Or Not\n\nGuests: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) ( @annameison )\n\n[https://twitch.tv/evangelist___](https://twitch.tv/evangelist___) ( @evangelist_scav )\n\nStream on January 22, 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/7fDk0ZmrE74/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/7fDk0ZmrE74/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/7fDk0ZmrE74/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/7fDk0ZmrE74/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/7fDk0ZmrE74/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 229, + "resourceId": { + "kind": "youtube#video", + "videoId": "7fDk0ZmrE74" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "7fDk0ZmrE74", + "videoPublishedAt": "2025-01-31T11:00:08Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "6zLRG_HU_4Jc2-9e0cdnUrUWRFg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnJqaGxjbnl4cEFz", + "snippet": { + "publishedAt": "2025-01-30T17:01:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "More Outlast Trials. Helping masutifu_ with the new Trial", + "description": "Casual Outlast Trials gameplay\nPlaying the new Trial \"Pleasure the Prosecuter\" plus Weekly Trials\n\nGuests:\nhttps://twitch.tv/annameison ( @annameison )\nhttps://twitch.tv/masutifu_\n\nStream on January 19. 2025 -- Watch live: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/rjhlcnyxpAs/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/rjhlcnyxpAs/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/rjhlcnyxpAs/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/rjhlcnyxpAs/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/rjhlcnyxpAs/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 230, + "resourceId": { + "kind": "youtube#video", + "videoId": "rjhlcnyxpAs" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "rjhlcnyxpAs", + "videoPublishedAt": "2025-01-30T17:01:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "OgCdJMpUP9HzfLBJWjz1-mRbpNw", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlRTTDJHdzdrU0Nv", + "snippet": { + "publishedAt": "2025-01-30T11:00:37Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Once Human - Getting back into the way of winter", + "description": "Casual Once Human gameplay in \"The Way of Winter\"\n\nStream on January 19. 2025", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/TSL2Gw7kSCo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/TSL2Gw7kSCo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/TSL2Gw7kSCo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/TSL2Gw7kSCo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/TSL2Gw7kSCo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 231, + "resourceId": { + "kind": "youtube#video", + "videoId": "TSL2Gw7kSCo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "TSL2Gw7kSCo", + "videoPublishedAt": "2025-01-30T11:00:37Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "hb38ARbbCrKbMg6Fr6_pf6B1h1Y", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmNvT3lHRllZNXpJ", + "snippet": { + "publishedAt": "2025-01-29T17:00:42Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Portal 2 Multiplayer with @annameison - not that much brainbreaking this time", + "description": "Playing the base Portal 2 Multiplayer (first half) with @annameison \n\nGuest:\nhttps://twitch.tv/annameison ( @annameison )\n\nStream on January 19. 2025 -- watch live: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/coOyGFYY5zI/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/coOyGFYY5zI/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/coOyGFYY5zI/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/coOyGFYY5zI/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/coOyGFYY5zI/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 232, + "resourceId": { + "kind": "youtube#video", + "videoId": "coOyGFYY5zI" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "coOyGFYY5zI", + "videoPublishedAt": "2025-01-29T17:00:42Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Q10hnuTtwwd7AUdgOUq_ZUS1Ecs", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkYyTVNIMEU0ZE5j", + "snippet": { + "publishedAt": "2025-01-29T11:00:28Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Outlast Trials with friends! Playing the new Trial and more", + "description": "Casual Outlast Trials gameplay\nPlaying the new Trial \"Pleasure the Prosecuter\"\nWeekly Trials\n\nGuests:\nhttps://twitch.tv/annameison ( @annameison )\nhttps://twitch.tv/axoloaded ( @Axoloaded )\n\nStream on January 19. 2025 -- Watch live: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/F2MSH0E4dNc/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/F2MSH0E4dNc/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/F2MSH0E4dNc/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/F2MSH0E4dNc/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/F2MSH0E4dNc/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 233, + "resourceId": { + "kind": "youtube#video", + "videoId": "F2MSH0E4dNc" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "F2MSH0E4dNc", + "videoPublishedAt": "2025-01-29T11:00:28Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "gNAphWpFWyVHHyvJkOz7_8WtaAQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLm4zY204SDZUejRZ", + "snippet": { + "publishedAt": "2025-01-28T11:00:53Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Water! We need WATER!!! Forever Winter with @evangelist_scav", + "description": "Getting the water in The Forever Winter\n\nGuest: \n\n[https://twitch.tv/evangelist___](https://twitch.tv/evangelist___) ( @evangelist_scav )\n\nStream on January 19. 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/n3cm8H6Tz4Y/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/n3cm8H6Tz4Y/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/n3cm8H6Tz4Y/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/n3cm8H6Tz4Y/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/n3cm8H6Tz4Y/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 234, + "resourceId": { + "kind": "youtube#video", + "videoId": "n3cm8H6Tz4Y" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "n3cm8H6Tz4Y", + "videoPublishedAt": "2025-01-28T11:00:53Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "OCk2zCVP2n52L8CfNJR3i0hKlLI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmhPQ1g1UFEyb013", + "snippet": { + "publishedAt": "2025-01-27T11:00:50Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Breaking the brain with Time-Portals again. Portal Reloaded Chambers 16 - Finish", + "description": "Finishing the Playthrough of Portal Reloaded (Chambers 16 - Finish)\n\nFirst time playing\n\nStream on January 15. 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/hOCX5PQ2oMw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/hOCX5PQ2oMw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/hOCX5PQ2oMw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/hOCX5PQ2oMw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/hOCX5PQ2oMw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 235, + "resourceId": { + "kind": "youtube#video", + "videoId": "hOCX5PQ2oMw" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "hOCX5PQ2oMw", + "videoPublishedAt": "2025-01-27T11:00:50Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "PN0cqukMbPsCbRnBDkHHR6_-gnI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkhadEFuT2dWZXdv", + "snippet": { + "publishedAt": "2025-01-26T11:00:19Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Getting out of the chambers! GTF and O with @kyuvulpes and @annameison", + "description": "Casual GTFO Gameplay. Getting Kyu through some Chambers\n\nGuests: \n[https://twitch.tv/kyuvulpes](https://twitch.tv/kyuvulpes) \n[https://twitch.tv/annameison](https://twitch.tv/annameison)\n\nStream on January 07. 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/HZtAnOgVewo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/HZtAnOgVewo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/HZtAnOgVewo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/HZtAnOgVewo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/HZtAnOgVewo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 236, + "resourceId": { + "kind": "youtube#video", + "videoId": "HZtAnOgVewo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "HZtAnOgVewo", + "videoPublishedAt": "2025-01-26T11:00:19Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "gk0-wiJOSgDe1hOfBDPYm5UNKqU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlhzZVA4MzRtbzRJ", + "snippet": { + "publishedAt": "2025-01-25T11:00:49Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "First stream of the year! Outer Wilds with @Scarofman", + "description": "First time playing Outer Wilds.\n\nQuantum Space buddies with @scarofman\n\nGuests: \n[https://twitch.tv/scarofman](https://twitch.tv/scarofman) \n\nhttps.//twitch.tv/annameison\n\nStream on January 07. 2025 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/XseP834mo4I/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/XseP834mo4I/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/XseP834mo4I/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/XseP834mo4I/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/XseP834mo4I/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 237, + "resourceId": { + "kind": "youtube#video", + "videoId": "XseP834mo4I" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "XseP834mo4I", + "videoPublishedAt": "2025-01-25T11:00:49Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "c33ZlRuTQ5T_qBHGnFoT39FZ6NM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlNZbEhYX0NoRzBR", + "snippet": { + "publishedAt": "2025-01-24T11:00:39Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Last stream of the year! Once Human with @stars_vr", + "description": "Chill OnceHuman gameplay with @stars_vr\n\nThe Way of Winter\n\nGuest: \n\n[https://twitch.tv/stars_vr](https://twitch.tv/stars_vr)\n\nStream on December 29. 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/SYlHX_ChG0Q/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/SYlHX_ChG0Q/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/SYlHX_ChG0Q/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/SYlHX_ChG0Q/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/SYlHX_ChG0Q/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 238, + "resourceId": { + "kind": "youtube#video", + "videoId": "SYlHX_ChG0Q" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "SYlHX_ChG0Q", + "videoPublishedAt": "2025-01-24T11:00:39Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "W4MaKPHUCEoKmtLN7tgbJ4CbS3U", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjBKTm52Zl9aUGJz", + "snippet": { + "publishedAt": "2025-01-23T11:00:01Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Portal Reloaded Multiplayer - Single Braincell Moments with @annameison", + "description": "Portal Reloaded Multiplayer Playthrough\n\nFirst time playing Reloaded Multiplayer\n\nGuest:\nhttps://twitch.tv/annameison\n\nStream on 28. December 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/0JNnvf_ZPbs/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/0JNnvf_ZPbs/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/0JNnvf_ZPbs/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/0JNnvf_ZPbs/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/0JNnvf_ZPbs/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 239, + "resourceId": { + "kind": "youtube#video", + "videoId": "0JNnvf_ZPbs" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "0JNnvf_ZPbs", + "videoPublishedAt": "2025-01-23T11:00:01Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "4Lm1Zq0UJmsrDWQJxcuFheqrpEk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjRSTThndFpJY004", + "snippet": { + "publishedAt": "2025-01-22T11:00:09Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Once Human Christmas stream with @starsgaming", + "description": "Full Playthrough of Portal 1\n\nChristmas stream with giveaways. All giveaways were only during the stream!\n\nGuest: \nhttps://twitch.tv/stars_vr ( @starsgaming )\n\nStream on December 25. 2024 -- Watch live: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/4RM8gtZIcM8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/4RM8gtZIcM8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/4RM8gtZIcM8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/4RM8gtZIcM8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/4RM8gtZIcM8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 240, + "resourceId": { + "kind": "youtube#video", + "videoId": "4RM8gtZIcM8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "4RM8gtZIcM8", + "videoPublishedAt": "2025-01-22T11:00:09Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Xsb-R32G5fIPddq2l_jU6jtPlxY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnpxY2RUV2tibjlZ", + "snippet": { + "publishedAt": "2025-01-21T11:01:17Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Portal Reloaded - First time playing Chambers 1-15 - Christmas Stream", + "description": "Portal Reloaded - Playthrough Chamber 1-15\n\nChristmas stream with giveaways. All giveaways were only during the stream!\n\nStream on December 25. 2024 -- Watch live: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/zqcdTWkbn9Y/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/zqcdTWkbn9Y/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/zqcdTWkbn9Y/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/zqcdTWkbn9Y/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/zqcdTWkbn9Y/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 241, + "resourceId": { + "kind": "youtube#video", + "videoId": "zqcdTWkbn9Y" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "zqcdTWkbn9Y", + "videoPublishedAt": "2025-01-21T11:01:17Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "W_Xw4TA5CWA1MXSqzGsa3ENeOxA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLm1ZYnlYWU9MeFAw", + "snippet": { + "publishedAt": "2025-01-20T11:00:56Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Portal 2 Full playthrough - Christmas Stream", + "description": "Full Playthrough of Portal 2\n\nChristmas stream with giveaways. All giveaways were only during the stream!\n\nStream on December 25. 2024 -- Watch live: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/mYbyXYOLxP0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/mYbyXYOLxP0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/mYbyXYOLxP0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/mYbyXYOLxP0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/mYbyXYOLxP0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 242, + "resourceId": { + "kind": "youtube#video", + "videoId": "mYbyXYOLxP0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "mYbyXYOLxP0", + "videoPublishedAt": "2025-01-20T11:00:56Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "YEhjz3zkQH9lR4NjtBYf7YxmAFQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnBfSjJadXRuMlBV", + "snippet": { + "publishedAt": "2025-01-19T11:00:56Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Portal 1 RTX full playthrough - Christmas Stream", + "description": "Full Playthrough of Portal 1 RTX\n\nChristmas stream with giveaways. All giveaways were only during the stream!\n\nStream on December 25. 2024 -- Watch live: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/p_J2Zutn2PU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/p_J2Zutn2PU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/p_J2Zutn2PU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/p_J2Zutn2PU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/p_J2Zutn2PU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 243, + "resourceId": { + "kind": "youtube#video", + "videoId": "p_J2Zutn2PU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "p_J2Zutn2PU", + "videoPublishedAt": "2025-01-19T11:00:56Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "vS2b5VFnYNqI_jMvo8bc7XiVj00", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjMzMVdHb0VoV3Nn", + "snippet": { + "publishedAt": "2025-01-18T09:43:28Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Portal 1 Full playthrough - Christmas Stream", + "description": "Full Playthrough of Portal 1\n\nChristmas stream with giveaways. All giveaways were only during the stream!\n\nStream on December 25. 2024 -- Watch live: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/331WGoEhWsg/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/331WGoEhWsg/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/331WGoEhWsg/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/331WGoEhWsg/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/331WGoEhWsg/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 244, + "resourceId": { + "kind": "youtube#video", + "videoId": "331WGoEhWsg" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "331WGoEhWsg", + "videoPublishedAt": "2025-01-18T09:43:28Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "bH6R92VZWhyffFC3s8lqrC6qbrU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkJrMEtrV3NIeVJF", + "snippet": { + "publishedAt": "2025-01-04T11:00:12Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Playing in the snow w/ @scarofman", + "description": "Casual Gameplay of Snowrunner with @scarofman\n\nGuest: \n\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman)\n\nStream on 22. December 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Bk0KkWsHyRE/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Bk0KkWsHyRE/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Bk0KkWsHyRE/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Bk0KkWsHyRE/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Bk0KkWsHyRE/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 245, + "resourceId": { + "kind": "youtube#video", + "videoId": "Bk0KkWsHyRE" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Bk0KkWsHyRE", + "videoPublishedAt": "2025-01-04T11:00:12Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "-H6ATJyzN3K_ALvFddaz8qQmBIY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnJMVjlPb19TbVlZ", + "snippet": { + "publishedAt": "2025-01-03T11:01:06Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Wintertime once more! Starting in OnceHuman with The Way Of Winter | w/ @stars_vr | !dc !commands !m", + "description": "Fresh start in Once Human - The way of winter\n\nGuest: \n\n[https://twitch.tv/stars_vr](https://twitch.tv/stars_vr)\n\nStream on 20. December 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/rLV9Oo_SmYY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/rLV9Oo_SmYY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/rLV9Oo_SmYY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/rLV9Oo_SmYY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/rLV9Oo_SmYY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 246, + "resourceId": { + "kind": "youtube#video", + "videoId": "rLV9Oo_SmYY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "rLV9Oo_SmYY", + "videoPublishedAt": "2025-01-03T11:01:06Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "qodPHNBLublg42ZA5mi2t2LRFXU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnlseXN2aDl1TXE4", + "snippet": { + "publishedAt": "2025-01-02T11:01:14Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Wintertime! FOREVER!!! Getting out of Water-Death | w/ @evangelist___", + "description": "Casual Forever Winter gameplay\n\nGuest: \n\n[https://twitch.tv/evangelist___](https://twitch.tv/evangelist___)\n\nStream on 18. December 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/ylysvh9uMq8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/ylysvh9uMq8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/ylysvh9uMq8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/ylysvh9uMq8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/ylysvh9uMq8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 247, + "resourceId": { + "kind": "youtube#video", + "videoId": "ylysvh9uMq8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "ylysvh9uMq8", + "videoPublishedAt": "2025-01-02T11:01:14Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "LBkqxePCyZOOyv_E_E7mPyodR5E", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkw0RUptSFEyRE5Z", + "snippet": { + "publishedAt": "2025-01-01T11:00:22Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Satisfactory Production Building | w/ @stars_vr", + "description": "Chaotic Satisfactory Building with @stars_vr\n\nGuest: \n\n[https://twitch.tv/stars_vr](https://twitch.tv/stars_vr)\n\nStream on 15. December 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/L4EJmHQ2DNY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/L4EJmHQ2DNY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/L4EJmHQ2DNY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/L4EJmHQ2DNY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/L4EJmHQ2DNY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 248, + "resourceId": { + "kind": "youtube#video", + "videoId": "L4EJmHQ2DNY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "L4EJmHQ2DNY", + "videoPublishedAt": "2025-01-01T11:00:22Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "WAKlX3-NHXsxnbtP_KKtuAt33fk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjBwYkhLN0ZlYjlZ", + "snippet": { + "publishedAt": "2024-12-31T11:00:02Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Its continuing to look a lot like Ficsmas", + "description": "chill Satisfactory gameplay in the Ficsmas event\n\nStream on 13. December 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/0pbHK7Feb9Y/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/0pbHK7Feb9Y/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/0pbHK7Feb9Y/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/0pbHK7Feb9Y/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/0pbHK7Feb9Y/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 249, + "resourceId": { + "kind": "youtube#video", + "videoId": "0pbHK7Feb9Y" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "0pbHK7Feb9Y", + "videoPublishedAt": "2024-12-31T11:00:02Z" + }, + "status": { + "privacyStatus": "public" + } + } + ], + "pageInfo": { + "totalResults": 405, + "resultsPerPage": 50 + } + }, + { + "kind": "youtube#playlistItemListResponse", + "etag": "aQKzE-U59JQlpc3LRDtqAbnKwPM", + "nextPageToken": "EAAaB1BUOkNLd0M", + "prevPageToken": "EAEaB1BUOkNQb0I", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "SML0ahrhcQ9H35ek7gtZ0ATJdns", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkw0RUptSFEyRE5Z", + "snippet": { + "publishedAt": "2025-01-01T11:00:22Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Satisfactory Production Building | w/ @stars_vr", + "description": "Chaotic Satisfactory Building with @stars_vr\n\nGuest: \n\n[https://twitch.tv/stars_vr](https://twitch.tv/stars_vr)\n\nStream on 15. December 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/L4EJmHQ2DNY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/L4EJmHQ2DNY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/L4EJmHQ2DNY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/L4EJmHQ2DNY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/L4EJmHQ2DNY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 250, + "resourceId": { + "kind": "youtube#video", + "videoId": "L4EJmHQ2DNY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "L4EJmHQ2DNY", + "videoPublishedAt": "2025-01-01T11:00:22Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "tP-1Kg3a-SHvyCSOx-AsxoltaKc", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjBwYkhLN0ZlYjlZ", + "snippet": { + "publishedAt": "2024-12-31T11:00:02Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Its continuing to look a lot like Ficsmas", + "description": "chill Satisfactory gameplay in the Ficsmas event\n\nStream on 13. December 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/0pbHK7Feb9Y/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/0pbHK7Feb9Y/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/0pbHK7Feb9Y/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/0pbHK7Feb9Y/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/0pbHK7Feb9Y/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 251, + "resourceId": { + "kind": "youtube#video", + "videoId": "0pbHK7Feb9Y" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "0pbHK7Feb9Y", + "videoPublishedAt": "2024-12-31T11:00:02Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "dEbGckggo1jVM8agQOgOHFEDf-4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmEtUlF3bEotMS1Z", + "snippet": { + "publishedAt": "2024-12-30T11:00:01Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Finishing Little Nightmares 2 - First Playthrough", + "description": "First Playthrough of Little Nightmares 2\n\nStream on 11. December 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/a-RQwlJ-1-Y/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/a-RQwlJ-1-Y/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/a-RQwlJ-1-Y/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/a-RQwlJ-1-Y/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/a-RQwlJ-1-Y/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 252, + "resourceId": { + "kind": "youtube#video", + "videoId": "a-RQwlJ-1-Y" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "a-RQwlJ-1-Y", + "videoPublishedAt": "2024-12-30T11:00:01Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "_djmq9dP3Vx6Ap2Z1PajtegYcJA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnlZWDlXd0VmaFpz", + "snippet": { + "publishedAt": "2024-12-13T06:52:37Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "The Borderlands are calling, and we are answering!", + "description": "Into the Borderlands! \n\nBorderlands 2 Fresh start with @luminere_flash\n\nGuest: [https://twitch.tv/luminere_flash](https://twitch.tv/luminere_flash)\n\nStream on 06. December 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/yYX9WwEfhZs/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/yYX9WwEfhZs/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/yYX9WwEfhZs/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/yYX9WwEfhZs/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/yYX9WwEfhZs/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 253, + "resourceId": { + "kind": "youtube#video", + "videoId": "yYX9WwEfhZs" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "yYX9WwEfhZs", + "videoPublishedAt": "2024-12-13T06:52:37Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "0NiTkwmJCG2ADSigBb6g0uGLvsI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnRaQUlMcGJ0LXdJ", + "snippet": { + "publishedAt": "2024-12-12T13:01:06Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Little nightmares 2! First playthrough", + "description": "First time playing Little Nightmares 2\n\nStream on 04. December 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/tZAILpbt-wI/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/tZAILpbt-wI/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/tZAILpbt-wI/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/tZAILpbt-wI/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/tZAILpbt-wI/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 254, + "resourceId": { + "kind": "youtube#video", + "videoId": "tZAILpbt-wI" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "tZAILpbt-wI", + "videoPublishedAt": "2024-12-12T13:01:06Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "2wfCaHTzjkVPXk1nW4Hr6nocyho", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLm5YVkN2UEhleHdz", + "snippet": { + "publishedAt": "2024-12-12T11:00:52Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "its beginning to look a lot like FICS-MAS", + "description": "Chill Satisfactory 1.0 Gameplay\n\nFirst day of the Satisfactory FICSMAS event\n\nStream on 01. December 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/nXVCvPHexws/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/nXVCvPHexws/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/nXVCvPHexws/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/nXVCvPHexws/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/nXVCvPHexws/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 255, + "resourceId": { + "kind": "youtube#video", + "videoId": "nXVCvPHexws" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "nXVCvPHexws", + "videoPublishedAt": "2024-12-12T11:00:52Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "P7eNBzS77cELEKtnDmrAWn6iTnw", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjRibzN6em5FcmZv", + "snippet": { + "publishedAt": "2024-12-12T09:35:16Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Breaking my brain on Satisfactory again", + "description": "Chill Satisfactory 1.0 Gameplay\n\nStream on 29. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/4bo3zznErfo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/4bo3zznErfo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/4bo3zznErfo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/4bo3zznErfo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/4bo3zznErfo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 256, + "resourceId": { + "kind": "youtube#video", + "videoId": "4bo3zznErfo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "4bo3zznErfo", + "videoPublishedAt": "2024-12-12T09:35:16Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "alfbp_3hS4jfRfeQDQPfe8mUnG0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnUwa0ZucE5wb0Fr", + "snippet": { + "publishedAt": "2024-11-29T13:53:58Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Little Nightmares DLC - First time playing (FULL PLAYTHROUGH)", + "description": "First and full playthrough of Little Nightmares DLC Content\n\nStream on 28. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/u0kFnpNpoAk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/u0kFnpNpoAk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/u0kFnpNpoAk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/u0kFnpNpoAk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/u0kFnpNpoAk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 257, + "resourceId": { + "kind": "youtube#video", + "videoId": "u0kFnpNpoAk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "u0kFnpNpoAk", + "videoPublishedAt": "2024-11-29T13:53:58Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "OMilwpaRFpxG5dIEgpv-D5a0qoc", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkJpV0V3U3pEa1RN", + "snippet": { + "publishedAt": "2024-11-25T12:00:21Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Chill Satisfactory exploring and progression", + "description": "Chill satisfactory gameplay. \n\nExploring to get a few new Crash Sites\n\nGuets: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\n[https://twitch.tv/tchaosdemon](https://twitch.tv/tchaosdemon) \n\nStream on 24. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/BiWEwSzDkTM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/BiWEwSzDkTM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/BiWEwSzDkTM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/BiWEwSzDkTM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/BiWEwSzDkTM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 258, + "resourceId": { + "kind": "youtube#video", + "videoId": "BiWEwSzDkTM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "BiWEwSzDkTM", + "videoPublishedAt": "2024-11-25T12:00:21Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "TZv27ba2TfGIqSwlgDL0NL5Dy5A", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkRVQXlHTm96RXM0", + "snippet": { + "publishedAt": "2024-11-21T09:07:38Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Big? Medium? Small? Nope! Little Nightmares! - First time playing (FULL PLAYTHROUGH)", + "description": "First time playing Little nightmares\n\nStream on 20. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/DUAyGNozEs4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/DUAyGNozEs4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/DUAyGNozEs4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/DUAyGNozEs4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/DUAyGNozEs4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 259, + "resourceId": { + "kind": "youtube#video", + "videoId": "DUAyGNozEs4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "DUAyGNozEs4", + "videoPublishedAt": "2024-11-21T09:07:38Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "ozbN2LO-bb_FoeVMp6Wjk4U9Fyo", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjVzNUNfSTBRSEdN", + "snippet": { + "publishedAt": "2024-11-18T16:30:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Getting Steel and Concrete! Closing in on the Spaceelevator progress", + "description": "Calm Satisfactory gameplay\n\nStream on 17. November 2024 -- watch live on Twitch: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/5s5C_I0QHGM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/5s5C_I0QHGM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/5s5C_I0QHGM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/5s5C_I0QHGM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/5s5C_I0QHGM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 260, + "resourceId": { + "kind": "youtube#video", + "videoId": "5s5C_I0QHGM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "5s5C_I0QHGM", + "videoPublishedAt": "2024-11-18T16:30:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Rze0JB4yvYd2QU_7rUt1PDTE5_Y", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnAtWmR3ZnB0Z3g4", + "snippet": { + "publishedAt": "2024-11-18T14:27:27Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Getting shot at in Apex Legends - can I (a noob) get some shots in?", + "description": "Casual Apex Legends with friends!\n\nGuests:\nhttps://twitch.tv/annameison (@annameison )\nhttps://twitch.tv/fayne_va\n\nStream on 17. November 2024 -- watch live on Twitch: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/p-Zdwfptgx8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/p-Zdwfptgx8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/p-Zdwfptgx8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/p-Zdwfptgx8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/p-Zdwfptgx8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 261, + "resourceId": { + "kind": "youtube#video", + "videoId": "p-Zdwfptgx8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "p-Zdwfptgx8", + "videoPublishedAt": "2024-11-18T14:27:27Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "1c4eRmT8hSriIQOwbv_vie4a0Qw", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmVJMWlzMHlvSkhr", + "snippet": { + "publishedAt": "2024-11-18T12:53:16Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Robbing everything with only one arm? One Armed Robber with friends", + "description": "First time playing the One Armed Robber with friends\n\nGuests:\nhttps://twitch.tv/axoloaded (@Axoloaded )\nhttps://twitch.tv/jarothorn (@Jarothorn )\nhttps://twitch.tv/mooseman06 (@MoosemanEX )\n\nStream on 17. November 2024 -- watch live on Twitch: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/eI1is0yoJHk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/eI1is0yoJHk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/eI1is0yoJHk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/eI1is0yoJHk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/eI1is0yoJHk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 262, + "resourceId": { + "kind": "youtube#video", + "videoId": "eI1is0yoJHk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "eI1is0yoJHk", + "videoPublishedAt": "2024-11-18T12:53:16Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "s82G2h2rhjZ8-7TDco5DAVV7IRI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkI3MEVFYVJHbmxj", + "snippet": { + "publishedAt": "2024-11-18T07:46:25Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Forever Winter - Back into it to gain knowledge | w/ @evangelist___", + "description": "Learning the basics of The Forever Winter\n\nGuest: \n\n[https://twitch.tv/evangelist___](https://twitch.tv/evangelist___)\n\nStream on 16. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/B70EEaRGnlc/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/B70EEaRGnlc/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/B70EEaRGnlc/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/B70EEaRGnlc/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/B70EEaRGnlc/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 263, + "resourceId": { + "kind": "youtube#video", + "videoId": "B70EEaRGnlc" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "B70EEaRGnlc", + "videoPublishedAt": "2024-11-18T07:46:25Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "GaPSHIxole2RHbjEyoa0C7nldhk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkFkMU1QVFBUbnBF", + "snippet": { + "publishedAt": "2024-11-16T16:00:14Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Coal, Iron plates and screws - lots to do in the Satisfactory addiction", + "description": "Casual Satisfactory 1.0 gameplay \n\nFinishing the Coal Powerplants\nFinishing Iron Plates \nFinishing Modular Frames\n\nStream on 15. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Ad1MPTPTnpE/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Ad1MPTPTnpE/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Ad1MPTPTnpE/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Ad1MPTPTnpE/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Ad1MPTPTnpE/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 264, + "resourceId": { + "kind": "youtube#video", + "videoId": "Ad1MPTPTnpE" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Ad1MPTPTnpE", + "videoPublishedAt": "2024-11-16T16:00:14Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "KTa6_X4oq08OIeIE63huaN8cxy8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkVqejg1bTdfNnk0", + "snippet": { + "publishedAt": "2024-11-14T09:44:24Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Forever Winter - First look and impressions | w/ @scarofman", + "description": "First impressions of The Forever Winter together with @scarofman\n\nGuest: \n\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman)\n\nStream on 13. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Ejz85m7_6y4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Ejz85m7_6y4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Ejz85m7_6y4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Ejz85m7_6y4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Ejz85m7_6y4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 265, + "resourceId": { + "kind": "youtube#video", + "videoId": "Ejz85m7_6y4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Ejz85m7_6y4", + "videoPublishedAt": "2024-11-14T09:44:24Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "EVzhqzIsfL_C2qSJ_P96SMK2Hpc", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlZsUG5STUJvaHZR", + "snippet": { + "publishedAt": "2024-11-12T11:28:59Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Iron, Quickwire, Coal and a lot of screws", + "description": "Casual calm Satisfactory 1.0 gameplay\n\nGuest: [https://twitch.tv/annameison](https://twitch.tv/annameison)\n\nStream on 09. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/VlPnRMBohvQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/VlPnRMBohvQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/VlPnRMBohvQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/VlPnRMBohvQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/VlPnRMBohvQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 266, + "resourceId": { + "kind": "youtube#video", + "videoId": "VlPnRMBohvQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "VlPnRMBohvQ", + "videoPublishedAt": "2024-11-12T11:28:59Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "xd8vpCtAjEeb-uY_ZeBlKIiFKQY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnBKbHdJdlVlc2dn", + "snippet": { + "publishedAt": "2024-11-11T11:01:04Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Calm Satisfactory Gameplay", + "description": "Casual Satisfactory Gameplay\nContinuing on the Stream World\n\n\nStream on 08. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/pJlwIvUesgg/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/pJlwIvUesgg/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/pJlwIvUesgg/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/pJlwIvUesgg/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/pJlwIvUesgg/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 267, + "resourceId": { + "kind": "youtube#video", + "videoId": "pJlwIvUesgg" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "pJlwIvUesgg", + "videoPublishedAt": "2024-11-11T11:01:04Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "phj81m-InFS-sxGgV14kzpun9pE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkUyWUNDRFBmRng4", + "snippet": { + "publishedAt": "2024-11-11T07:54:16Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Satisfactory - What have we done? | 36 Hour Stream Part 10", + "description": "Checking in on the factory we started all the way in the beginning. Also Rating the Chaos.\nPart 10 of the 36 hour stream Videos\n\nThe 36 hour stream was done by me and @scarofman1426 (https://twitch.tv/scarofman) on the 1st of November.\n\nGuests were other streamers and friends for different timeslots.\n\nGuest: \nhttps://twitch.tv/scarofman\nhttps://twitch.tv/annameison (@annameison )\n\nStream on 02. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/E2YCCDPfFx8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/E2YCCDPfFx8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/E2YCCDPfFx8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/E2YCCDPfFx8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/E2YCCDPfFx8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 268, + "resourceId": { + "kind": "youtube#video", + "videoId": "E2YCCDPfFx8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "E2YCCDPfFx8", + "videoPublishedAt": "2024-11-11T07:54:16Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "YguTHJBo9aeUNgslcqGuy_-tGgU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk1KQk5jdnRIZWpJ", + "snippet": { + "publishedAt": "2024-11-11T07:51:31Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Lethal Company - Can we survive the company? (or are we the problem?) | 36h Stream Part 9", + "description": "After 30 hours are already up, its time to jump into the madness with Lethal Company. Trying as a big group, its not sure if its deadlier to be in the facility or with our friends.\nPart 9 of the 36 hour stream Videos\n\nThe 36 hour stream was done by me and @scarofman1426 (https://twitch.tv/scarofman) on the 1st of November.\n\nGuests were other streamers and friends for different timeslots.\n\nGuest: \nhttps://twitch.tv/scarofman\nhttps://twitch.tv/fayne_va\nhttps://twitch.tv/jarothorn\nhttps://twitch.tv/mooseman06\n\nStream on 02. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/MJBNcvtHejI/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/MJBNcvtHejI/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/MJBNcvtHejI/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/MJBNcvtHejI/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/MJBNcvtHejI/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 269, + "resourceId": { + "kind": "youtube#video", + "videoId": "MJBNcvtHejI" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "MJBNcvtHejI", + "videoPublishedAt": "2024-11-11T07:51:31Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "dX1sM6dzuVV1KSd435k5U5W5-K4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlRYME5IYWVkTzhZ", + "snippet": { + "publishedAt": "2024-11-11T07:47:14Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Minecraft - Border Hoarder - More Blocks to Search | 36h Stream Part 8", + "description": "Since Our Original plan of RocketLeage didnt work out, we decided to move forward with a little bit of Minecraft. Searching for more unique Items!\n\nDownload the Map and Datapack form the original creator here: https://simondmc.com/projects/border-hoarder\n\nPart 8 of the 36 hour stream Videos\n\nThe 36 hour stream was done by me and @scarofman1426 (https://twitch.tv/scarofman) on the 1st of November.\n\nGuests were other streamers and friends for different timeslots.\n\nGuest: \nhttps://twitch.tv/scarofman\nhttps://twitch.tv/jarothorn\nhttps://twitch.tv/mooseman06\n\nStream on 02. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/TX0NHaedO8Y/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/TX0NHaedO8Y/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/TX0NHaedO8Y/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/TX0NHaedO8Y/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/TX0NHaedO8Y/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 270, + "resourceId": { + "kind": "youtube#video", + "videoId": "TX0NHaedO8Y" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "TX0NHaedO8Y", + "videoPublishedAt": "2024-11-11T07:47:14Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "6NnsgnhalFePlFrNSOtRl5hi8wM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkVwak13eUd0WG9R", + "snippet": { + "publishedAt": "2024-11-11T07:42:02Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Valorant - Can we hit stuff while totally sleep deprived? | 36h Stream Part 7", + "description": "After the first 24 hours of the stream are up, we try our luck in Valorant.\nBeing completly sleep deprived, its time to shoot at enemies and try to score some points!\n\nPart 7 of the 36 hour stream Videos\n\nThe 36 hour stream was done by me and @scarofman1426 (https://twitch.tv/scarofman) on the 1st of November.\n\nGuests were other streamers and friends for different timeslots.\n\nGuest: \nhttps://twitch.tv/scarofman\nhttps://twitch.tv/annameison (@annameison )\nhttps://twitch.tv/goobystrm (@Goobystrm )\nhttps://twitch.tv/xerogaki\n\nStream on 02. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/EpjMwyGtXoQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/EpjMwyGtXoQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/EpjMwyGtXoQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/EpjMwyGtXoQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/EpjMwyGtXoQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 271, + "resourceId": { + "kind": "youtube#video", + "videoId": "EpjMwyGtXoQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "EpjMwyGtXoQ", + "videoPublishedAt": "2024-11-11T07:42:02Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "6InQ0nhb57KNdQC3M0NYLLKMc6k", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnZsaDNHaGxxbXRJ", + "snippet": { + "publishedAt": "2024-11-11T07:37:29Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "VRChat - Some Chill vibes with friends | 36h Stream Part 6", + "description": "Getting some chill vibes in the night with friends in VRC\nSadly I had to mute a little bit of the video because of copyright problems.\n\nPart 6 of the 36 hour stream Videos\n\nThe 36 hour stream was done by me and @scarofman1426 (https://twitch.tv/scarofman) on the 1st of November.\n\nGuests were other streamers and friends for different timeslots.\n\nGuest: \nhttps://twitch.tv/scarofman\nhttps://twitch.tv/thatfewpeople\nhttps://twitch.tv/hellfirekille\n\nStream on 02. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/vlh3GhlqmtI/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/vlh3GhlqmtI/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/vlh3GhlqmtI/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/vlh3GhlqmtI/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/vlh3GhlqmtI/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 272, + "resourceId": { + "kind": "youtube#video", + "videoId": "vlh3GhlqmtI" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "vlh3GhlqmtI", + "videoPublishedAt": "2024-11-11T07:37:29Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "HQDeEiXNFfQ-7u0lLy9DDuwqqtI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnlaTnVTODB2SE5F", + "snippet": { + "publishedAt": "2024-11-10T21:35:33Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Snowrunner - Getting stuck in some deeeep snow | 36h Stream Part 5", + "description": "Getting into Snowrunner again and trying to get some cargo to its destination\n\nPart 5 of the 36 hour stream Videos\n\nThe 36 hour stream was done by me and @scarofman1426 (https://twitch.tv/scarofman) on the 1st of November.\n\nGuests were other streamers and friends for different timeslots.\n\nGuest: \nhttps://twitch.tv/scarofman\nhttps://twitch.tv/thatfewpeople\n\nStream on 02. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/yZNuS80vHNE/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/yZNuS80vHNE/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/yZNuS80vHNE/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/yZNuS80vHNE/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/yZNuS80vHNE/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 273, + "resourceId": { + "kind": "youtube#video", + "videoId": "yZNuS80vHNE" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "yZNuS80vHNE", + "videoPublishedAt": "2024-11-10T21:35:33Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "WylDYHv-2TSjCE8ipxp8etQvIr4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlIwRGNJamlUMlM0", + "snippet": { + "publishedAt": "2024-11-10T21:31:52Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Deep Rock Galactic - Shooting and Mining | 36h Stream Part 4", + "description": "Jumping into the Chaos of Deep Rock Galactic with friends!\nSorry for the troubles in the beginning, DeepRock was acting up.\n\nPart 4 of the 36 hour stream Videos\n\nThe 36 hour stream was done by me and @scarofman1426 (https://twitch.tv/scarofman) on the 1st of November.\n\nGuests were other streamers and friends for different timeslots.\n\nGuest: \nhttps://twitch.tv/scarofman\nhttps://twitch.tv/makuta87\nhttps://twitch.tv/luminere_flash\n\nStream on 01. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/R0DcIjiT2S4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/R0DcIjiT2S4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/R0DcIjiT2S4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/R0DcIjiT2S4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/R0DcIjiT2S4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 274, + "resourceId": { + "kind": "youtube#video", + "videoId": "R0DcIjiT2S4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "R0DcIjiT2S4", + "videoPublishedAt": "2024-11-10T21:31:52Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "1gD05plATvidwpPqov5x4KU1uvQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjFScm95eVdTdGVB", + "snippet": { + "publishedAt": "2024-11-10T19:42:10Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Raft - Will we survive out in the Ocean? | 36h Stream Part 3", + "description": "A Chaotic beginning of a Raft survival game. A bunch of noobs and a few knowledgable people try to survive here\n\nPart 3 of the 36 hour stream Videos\n\nThe 36 hour stream was done by me and @scarofman1426 (https://twitch.tv/scarofman) on the 1st of November.\n\nGuests were other streamers and friends for different timeslots.\n\nGuest: \nhttps://twitch.tv/scarofman\nhttps://twitch.tv/makuta87\nhttps://twitch.tv/luminere_flash\nhttps://twitch.tv/ak_wonderland\n\nStream on 01. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/1RroyyWSteA/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/1RroyyWSteA/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/1RroyyWSteA/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/1RroyyWSteA/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/1RroyyWSteA/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 275, + "resourceId": { + "kind": "youtube#video", + "videoId": "1RroyyWSteA" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "1RroyyWSteA", + "videoPublishedAt": "2024-11-10T19:42:10Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Dyea_3mPEjPyMhV68GvGnA_5g-c", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnVoeVRWcGF3eDBB", + "snippet": { + "publishedAt": "2024-11-10T19:38:17Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Minecraft - Border Hoarder Challenge Map | 36h Stream Part 2", + "description": "Gameplay of the Minecraft Challenge Map \"Border Hoarder\".\n\nDownload the Map and Datapack form the original creator here: https://simondmc.com/projects/border-hoarder\n\nPart 2 of the 36 hour stream Videos\n\nThe 36 hour stream was done by me and @scarofman1426 (https://twitch.tv/scarofman) on the 1st of November.\n\nGuests were other streamers and friends for different timeslots.\n\nGuest: \nhttps://twitch.tv/scarofman\nhttps://twitch.tv/mooseman06\nhttps://twitch.tv/jarothorn\n\nStream on 01. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/uhyTVpawx0A/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/uhyTVpawx0A/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/uhyTVpawx0A/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/uhyTVpawx0A/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/uhyTVpawx0A/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 276, + "resourceId": { + "kind": "youtube#video", + "videoId": "uhyTVpawx0A" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "uhyTVpawx0A", + "videoPublishedAt": "2024-11-10T19:38:17Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "wdIYejajLrimkoGK9ALnuiG6QQk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLndIUXBOWXZqSlZz", + "snippet": { + "publishedAt": "2024-11-10T19:33:02Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Satisfactory - A start into Chaos! | 36h Stream Part 1", + "description": "Chaotic Satisfactory 1.0 gameplay\nPart 1 of the 36 hour stream Videos\n\nThe 36 hour stream was done by me and @scarofman1426 on the 1st of November.\n\nGuests were other streamers and friends for different timeslots.\n\nGuest: \nhttps://twitch.tv/scarofman\n\nStream on 01. November 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/wHQpNYvjJVs/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/wHQpNYvjJVs/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/wHQpNYvjJVs/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/wHQpNYvjJVs/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/wHQpNYvjJVs/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 277, + "resourceId": { + "kind": "youtube#video", + "videoId": "wHQpNYvjJVs" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "wHQpNYvjJVs", + "videoPublishedAt": "2024-11-10T19:33:02Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "qrdFMjR_37HgoRrftJ3RqfQYIH4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlctdmZrZFVpQUFv", + "snippet": { + "publishedAt": "2024-11-04T20:00:20Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "On we go! Coal and powerplants in Satisfactory", + "description": "Casual Satisfactory gameplay.\n\nStream on 27. October 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/W-vfkdUiAAo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/W-vfkdUiAAo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/W-vfkdUiAAo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/W-vfkdUiAAo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/W-vfkdUiAAo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 278, + "resourceId": { + "kind": "youtube#video", + "videoId": "W-vfkdUiAAo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "W-vfkdUiAAo", + "videoPublishedAt": "2024-11-04T20:00:20Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "-M0Se5q4yMmpxrIIJgI03hOhKJQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkhKeE9ScV84MTRn", + "snippet": { + "publishedAt": "2024-11-04T17:49:15Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "7 Minutes in Hell - Securing the price-money as much as possible", + "description": "Casual gemeplay of 7 Mnutes in Hell\n\nGuests: \nhttps://twitch.tv/annameison\nhttps://twitch.tv/scarofman\nhttps://twitch.tv/axoloaded\n\nStream on 27. October 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/HJxORq_814g/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/HJxORq_814g/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/HJxORq_814g/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/HJxORq_814g/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/HJxORq_814g/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 279, + "resourceId": { + "kind": "youtube#video", + "videoId": "HJxORq_814g" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "HJxORq_814g", + "videoPublishedAt": "2024-11-04T17:49:15Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "aA45vOx-WisT3wkQIPt9Y2sKwe8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnhqN2ZnRS1hSTg4", + "snippet": { + "publishedAt": "2024-10-27T12:01:13Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Wednesday Fun Day! Satisfactory it is! The factory must grow!", + "description": "Casual Satisfactory 1.0 gameplay\n\nStream on 16. October 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/xj7fgE-aI88/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/xj7fgE-aI88/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/xj7fgE-aI88/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/xj7fgE-aI88/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/xj7fgE-aI88/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 280, + "resourceId": { + "kind": "youtube#video", + "videoId": "xj7fgE-aI88" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "xj7fgE-aI88", + "videoPublishedAt": "2024-10-27T12:01:13Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "KSRrXzmdn_dHWxVQLu85qZbvw64", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmRoamJna005T1U4", + "snippet": { + "publishedAt": "2024-10-10T07:20:21Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "7 Minutes is what we get! Time to explore !?HELL?! | w/ @scarofman @annameison", + "description": "Casual Gameplay of the new game from Gaggle Studios: 7 Minutes in Hell\n\nGuests: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman)\n\nstream on 09. October 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/dhjbgkM9OU8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/dhjbgkM9OU8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/dhjbgkM9OU8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/dhjbgkM9OU8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/dhjbgkM9OU8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 281, + "resourceId": { + "kind": "youtube#video", + "videoId": "dhjbgkM9OU8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "dhjbgkM9OU8", + "videoPublishedAt": "2024-10-10T07:20:21Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "cpiIRjWdBRxExF9GAPlj-7pRAg0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmM0US1KTDVvanBz", + "snippet": { + "publishedAt": "2024-10-07T17:00:34Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Satisfying Chaos! Continuing the new Factory | w/ @annameison @scarofman", + "description": "Casual Gameplay of Satisfactory 1.0\n\nGuests: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n[https://twitch.tv/scarofman](https://twitch.tv/scarofman)\n\nStream on 06. October 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/c4Q-JL5ojps/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/c4Q-JL5ojps/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/c4Q-JL5ojps/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/c4Q-JL5ojps/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/c4Q-JL5ojps/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 282, + "resourceId": { + "kind": "youtube#video", + "videoId": "c4Q-JL5ojps" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "c4Q-JL5ojps", + "videoPublishedAt": "2024-10-07T17:00:34Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "RqQIlDoahRVFP2x9AC3l4Xdr55g", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmpDRWdVYy1ZcUo0", + "snippet": { + "publishedAt": "2024-10-07T15:00:48Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Satisfying Chaos! Continuing the new Factory | w/ @annameison", + "description": "Casual Satisfactory 1.0 gameplay\n\nGuest: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison)\n\nStream on 04. October 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/jCEgUc-YqJ4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/jCEgUc-YqJ4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/jCEgUc-YqJ4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/jCEgUc-YqJ4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/jCEgUc-YqJ4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 283, + "resourceId": { + "kind": "youtube#video", + "videoId": "jCEgUc-YqJ4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "jCEgUc-YqJ4", + "videoPublishedAt": "2024-10-07T15:00:48Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "gHuOYIMeisLAHw8Nvb1h1rqCqJs", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLl83Ykc3bHNZRTJN", + "snippet": { + "publishedAt": "2024-10-07T13:00:42Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Continuing the modded Minecraft adventure! ATM9 (Private Server)", + "description": "Modded Minecraft ATM9 (All the Mods 9) \n\nNew (Private) Server\n\nGuests: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison)\n\nStream on 02. October 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/_7bG7lsYE2M/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/_7bG7lsYE2M/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/_7bG7lsYE2M/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/_7bG7lsYE2M/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/_7bG7lsYE2M/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 284, + "resourceId": { + "kind": "youtube#video", + "videoId": "_7bG7lsYE2M" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "_7bG7lsYE2M", + "videoPublishedAt": "2024-10-07T13:00:42Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "s1TvWk8i6ljb2hARfUqXJFUmkUQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLm5lYzlsT0JYQlBV", + "snippet": { + "publishedAt": "2024-10-07T11:00:53Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "A new world starts! ATM9", + "description": "Modded Minecraft ATM9 (All the Mods 9) \n\nNew (Private) Server\n\nGuests: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n[https://twitch.tv/scarofman](https://twitch.tv/scarofman) \n[https://twitch.tv/yhuemad](https://twitch.tv/yhuemad) \n[https://twitch.tv/jarothorn](https://twitch.tv/jarothorn) \n[https://twitch.tv/mooseman06](https://twitch.tv/mooseman06) \nand more\n\nStream on 29. September 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/nec9lOBXBPU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/nec9lOBXBPU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/nec9lOBXBPU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/nec9lOBXBPU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/nec9lOBXBPU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 285, + "resourceId": { + "kind": "youtube#video", + "videoId": "nec9lOBXBPU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "nec9lOBXBPU", + "videoPublishedAt": "2024-10-07T11:00:53Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "pltyQjs2lrKKq7sldNUO-3vsvGA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLl9RMDc1cHV1emZr", + "snippet": { + "publishedAt": "2024-10-07T09:00:27Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Back from vacation! Starting from scratch in Satisfactory 1.0 | w/ @scarofman @annameison", + "description": "Casual Satisfactory 1.0 gameplay\n\nGuests: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman)\n\nStream on 28. September 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/_Q075puuzfk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/_Q075puuzfk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/_Q075puuzfk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/_Q075puuzfk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/_Q075puuzfk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 286, + "resourceId": { + "kind": "youtube#video", + "videoId": "_Q075puuzfk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "_Q075puuzfk", + "videoPublishedAt": "2024-10-07T09:00:27Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "S38QMGxvUHNgYEBfj10QnORj22o", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmw1dWJUYTZZemM4", + "snippet": { + "publishedAt": "2024-10-07T06:39:07Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Checking in on the Cult | Vacation Stream!", + "description": "Casual Cult of the Lamb stream in my Vacation\n\nStream on 14. September 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/l5ubTa6Yzc8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/l5ubTa6Yzc8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/l5ubTa6Yzc8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/l5ubTa6Yzc8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/l5ubTa6Yzc8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 287, + "resourceId": { + "kind": "youtube#video", + "videoId": "l5ubTa6Yzc8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "l5ubTa6Yzc8", + "videoPublishedAt": "2024-10-07T06:39:07Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Jhx_ZwvJLCH2oA2CAaZ2UR5MbJQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnBLbWhpLTB2ZVZR", + "snippet": { + "publishedAt": "2024-09-15T04:00:37Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU/ENG] To the Sea we go!", + "description": "Beenden der Main-Story von AC: Black Flag\n\nStream am 28. August 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/pKmhi-0veVQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/pKmhi-0veVQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/pKmhi-0veVQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/pKmhi-0veVQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/pKmhi-0veVQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 288, + "resourceId": { + "kind": "youtube#video", + "videoId": "pKmhi-0veVQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "pKmhi-0veVQ", + "videoPublishedAt": "2024-09-15T04:00:37Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "C_tIk0wTX_gDvGkcmglKbjoLHUk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjJCeW1sM3kyckdJ", + "snippet": { + "publishedAt": "2024-09-14T19:00:02Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Lets start a cult - First time playing COTL - Stream into my birthday!", + "description": "First impressions and casual gameplay of Cult Of The Lamb\n\nBirthday Stream\n\nStream on 24. August 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/2Byml3y2rGI/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/2Byml3y2rGI/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/2Byml3y2rGI/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/2Byml3y2rGI/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/2Byml3y2rGI/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 289, + "resourceId": { + "kind": "youtube#video", + "videoId": "2Byml3y2rGI" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "2Byml3y2rGI", + "videoPublishedAt": "2024-09-14T19:00:02Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "PqmJ_yVb2E5pGSPnISooJYlTEzQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkdrTmpTN1RvQTdn", + "snippet": { + "publishedAt": "2024-09-14T15:42:58Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "The Evil is within! Hopefully finishing the game today", + "description": "Finishing the 2nd Playthrouugh of The Evil Within\n\nStream on 23. August 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/GkNjS7ToA7g/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/GkNjS7ToA7g/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/GkNjS7ToA7g/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/GkNjS7ToA7g/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/GkNjS7ToA7g/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 290, + "resourceId": { + "kind": "youtube#video", + "videoId": "GkNjS7ToA7g" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "GkNjS7ToA7g", + "videoPublishedAt": "2024-09-14T15:42:58Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "AzTuD2sIWQcKWUNs1UOxM16w0Sw", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmFjSWFkU1BYb0xn", + "snippet": { + "publishedAt": "2024-08-21T08:00:03Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Continuing the Adventures in Tina Tinas Wonderlands | w/ @ak_wonderland", + "description": "Casual Gameplay of Tiny Tinas Wonderlands\n\nGuest:\nhttps://twitch.tv/ak_wonderland\n\nStream on 18. August 2024 -- watch live on https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/acIadSPXoLg/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/acIadSPXoLg/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/acIadSPXoLg/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/acIadSPXoLg/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/acIadSPXoLg/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 291, + "resourceId": { + "kind": "youtube#video", + "videoId": "acIadSPXoLg" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "acIadSPXoLg", + "videoPublishedAt": "2024-08-21T08:00:03Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "TRGEv9wk2smksjH8QxNEa51mq68", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLld5ZTFPSUk1ejRN", + "snippet": { + "publishedAt": "2024-08-21T06:00:13Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "Mentally deranged Team in mental institution | w/ @annameison @Axoloaded", + "description": "Chaotic first impression of Outlast trials\n\nGuests:\nhttps://twitch.tv/annameison\nhttps://twitch.tv/axoloaded\n\nStream on 18. August 2024 -- watchlive at https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Wye1OII5z4M/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Wye1OII5z4M/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Wye1OII5z4M/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Wye1OII5z4M/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Wye1OII5z4M/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 292, + "resourceId": { + "kind": "youtube#video", + "videoId": "Wye1OII5z4M" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Wye1OII5z4M", + "videoPublishedAt": "2024-08-21T06:00:13Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "69y53HgFV2iIALMMNTg4MEIGNPQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkVEQlp4dmdaem1N", + "snippet": { + "publishedAt": "2024-08-20T16:00:22Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "More Drainbamage! Continuing The Evil Within", + "description": "Casual playthrough of The Evil Within\n\nStream on 16. August 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/EDBZxvgZzmM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/EDBZxvgZzmM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/EDBZxvgZzmM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/EDBZxvgZzmM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/EDBZxvgZzmM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 293, + "resourceId": { + "kind": "youtube#video", + "videoId": "EDBZxvgZzmM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "EDBZxvgZzmM", + "videoPublishedAt": "2024-08-20T16:00:22Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "FCzDk0inaYSIwrfXRgnopV5hOfM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmUxazRkdkVXaUww", + "snippet": { + "publishedAt": "2024-08-20T15:00:41Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Back into the Wonderlands! Lets see what Tiny Tina has to offer again! | w/ @ak_wonderland", + "description": "Casual first playthrough of Tiny Tinas Wonderlands\n\nGuest: \n\n[https://twitch.tv/ak_wonderland](https://twitch.tv/ak_wonderland)\n\nStream on 13. August 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/e1k4dvEWiL0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/e1k4dvEWiL0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/e1k4dvEWiL0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/e1k4dvEWiL0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/e1k4dvEWiL0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 294, + "resourceId": { + "kind": "youtube#video", + "videoId": "e1k4dvEWiL0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "e1k4dvEWiL0", + "videoPublishedAt": "2024-08-20T15:00:41Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "-xP1chTTq_IXSvQkrfwqosm_P60", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmt2alNNMnc4NFVr", + "snippet": { + "publishedAt": "2024-08-20T14:00:48Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "On to new lands! Bringing the trucks back in and moving on to alaska", + "description": "Casual Gameplay of Snowrunner with @scarofman\n\nGuest: \n\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman)\n\nStream on 11. August 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/kvjSM2w84Uk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/kvjSM2w84Uk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/kvjSM2w84Uk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/kvjSM2w84Uk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/kvjSM2w84Uk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 295, + "resourceId": { + "kind": "youtube#video", + "videoId": "kvjSM2w84Uk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "kvjSM2w84Uk", + "videoPublishedAt": "2024-08-20T14:00:48Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "iuIdbTt8hVgEwVx8r7ohNXs0TR8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlV5TzVITXZObXE0", + "snippet": { + "publishedAt": "2024-08-20T13:00:26Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Goop for the masses! And Drainbamage... The Evil Within Continues", + "description": "Continuation of the playthrough of The Evil Within\n\nStream on 08. August 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/UyO5HMvNmq4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/UyO5HMvNmq4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/UyO5HMvNmq4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/UyO5HMvNmq4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/UyO5HMvNmq4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 296, + "resourceId": { + "kind": "youtube#video", + "videoId": "UyO5HMvNmq4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "UyO5HMvNmq4", + "videoPublishedAt": "2024-08-20T13:00:26Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "8mmJuFdVhik2vAaM6l76gXNNoxo", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlpKbmlOZzAzMTJj", + "snippet": { + "publishedAt": "2024-08-20T12:00:43Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU/ENG] Es gibt nur Wasser, Wasser, Wasser überall. Und wir haben nichts zu trinken", + "description": "Gemütliches playthrough von Assassins Creed Black Flag\n\nStream am 07. August 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/ZJniNg0312c/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/ZJniNg0312c/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/ZJniNg0312c/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/ZJniNg0312c/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/ZJniNg0312c/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 297, + "resourceId": { + "kind": "youtube#video", + "videoId": "ZJniNg0312c" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "ZJniNg0312c", + "videoPublishedAt": "2024-08-20T12:00:43Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "ePVY-vxeWoG3Tq6gKnpkATiwu80", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkVIaVhFT0J5TlU0", + "snippet": { + "publishedAt": "2024-08-20T10:33:57Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - Valorant brainrot with @annameison @cyc1z @Demon2ashes", + "description": "Casual Valorant gameplay with friends\n\nGuests:\nhttps://twitch.tv/annameison\nhttps://twitch.tv/cyc1z\nhttps://twitch.tv/tchaosdeamon\n\nStream on 04. August 2024 -- watch live on Twitcht https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/EHiXEOByNU4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/EHiXEOByNU4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/EHiXEOByNU4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/EHiXEOByNU4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/EHiXEOByNU4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 298, + "resourceId": { + "kind": "youtube#video", + "videoId": "EHiXEOByNU4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "EHiXEOByNU4", + "videoPublishedAt": "2024-08-20T10:33:57Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "x02OzC98ZNWtJQ06fpHWFtElIAE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmFHVXA2NkxFbEo0", + "snippet": { + "publishedAt": "2024-08-20T10:32:53Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Spontane Chess battle between @cyc1z and @Glacius_7", + "description": "Spontane Chess battle between @Cyc1z and @Glacius_7 in my stream\n\nGuests:\nhttps://twitch.tv/cyc1z\nhttps://twitch.tv/glacius_7\nhttps://twitch.tv/annameison\nhttps://twitch.tv/sylliantt\n\nStream on 04. August 2024 -- watch live on twitch https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/aGUp66LElJ4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/aGUp66LElJ4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/aGUp66LElJ4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/aGUp66LElJ4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/aGUp66LElJ4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 299, + "resourceId": { + "kind": "youtube#video", + "videoId": "aGUp66LElJ4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "aGUp66LElJ4", + "videoPublishedAt": "2024-08-20T10:32:53Z" + }, + "status": { + "privacyStatus": "public" + } + } + ], + "pageInfo": { + "totalResults": 405, + "resultsPerPage": 50 + } + }, + { + "kind": "youtube#playlistItemListResponse", + "etag": "C9vFH9LMNvUYrfVLRQ4klDz-fw0", + "nextPageToken": "EAAaB1BUOkNONEM", + "prevPageToken": "EAEaB1BUOkNLd0M", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "WeAuaL15hCoJ3yNCGHzUcD0-tYk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlpKbmlOZzAzMTJj", + "snippet": { + "publishedAt": "2024-08-20T12:00:43Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU/ENG] Es gibt nur Wasser, Wasser, Wasser überall. Und wir haben nichts zu trinken", + "description": "Gemütliches playthrough von Assassins Creed Black Flag\n\nStream am 07. August 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/ZJniNg0312c/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/ZJniNg0312c/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/ZJniNg0312c/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/ZJniNg0312c/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/ZJniNg0312c/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 300, + "resourceId": { + "kind": "youtube#video", + "videoId": "ZJniNg0312c" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "ZJniNg0312c", + "videoPublishedAt": "2024-08-20T12:00:43Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "zy-kBk0VrLiqVbNfyGQ-K_VSAEE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkYyWlB4WWt3NHdz", + "snippet": { + "publishedAt": "2024-08-20T10:10:53Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Mud, Trucks and Cargo - SnowRunner here we come | w/ @scarofman", + "description": "Casual Snowrunner Gameplay\n\nGuest: \n\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman)\n\nStream on 06. August 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/F2ZPxYkw4ws/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/F2ZPxYkw4ws/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/F2ZPxYkw4ws/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/F2ZPxYkw4ws/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/F2ZPxYkw4ws/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 301, + "resourceId": { + "kind": "youtube#video", + "videoId": "F2ZPxYkw4ws" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "F2ZPxYkw4ws", + "videoPublishedAt": "2024-08-20T10:10:53Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "-SVumzoJk9O_69MyJ3EArWZ8tTM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlJTbVBVNjd3SUhz", + "snippet": { + "publishedAt": "2024-08-08T17:37:27Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Shulker Business! Bringing a Shulker to the overworld", + "description": "Casual Minecraft gameplay on my Community-Server\n\nStream on 02. August 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/RSmPU67wIHs/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/RSmPU67wIHs/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/RSmPU67wIHs/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/RSmPU67wIHs/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/RSmPU67wIHs/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 302, + "resourceId": { + "kind": "youtube#video", + "videoId": "RSmPU67wIHs" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "RSmPU67wIHs", + "videoPublishedAt": "2024-08-08T17:37:27Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "lhsm0DaR90BHlAKhPjpEYNnKpRc", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnY0bWFYd1ZBUk5j", + "snippet": { + "publishedAt": "2024-08-08T15:47:41Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU/ENG] Die See ruft! und der Rum!", + "description": "Gemütliches Playthrough von Assassins-Creed Black Flag\n\nStream am 31. Juli 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/v4maXwVARNc/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/v4maXwVARNc/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/v4maXwVARNc/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/v4maXwVARNc/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/v4maXwVARNc/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 303, + "resourceId": { + "kind": "youtube#video", + "videoId": "v4maXwVARNc" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "v4maXwVARNc", + "videoPublishedAt": "2024-08-08T15:47:41Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "NuaFAWKENW5l2uJtryPJBY80oiE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjZaVnFiSUNkbVRz", + "snippet": { + "publishedAt": "2024-08-08T15:34:30Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Lightyear Frontier with @brutesx - Finishing the game", + "description": "Continuing the casual first playthrough of Lightyear Frontier\n\nGuest:\nhttps://twitch.tv/brutex\n\nStream on 28. July 2024", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/6ZVqbICdmTs/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/6ZVqbICdmTs/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/6ZVqbICdmTs/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/6ZVqbICdmTs/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/6ZVqbICdmTs/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 304, + "resourceId": { + "kind": "youtube#video", + "videoId": "6ZVqbICdmTs" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "6ZVqbICdmTs", + "videoPublishedAt": "2024-08-08T15:34:30Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "XU_drSw2z-m3k7DB6Jt4kR1dftw", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkJWTW9tbDNGUVJz", + "snippet": { + "publishedAt": "2024-08-08T10:19:23Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Dont Starve Together with @Axoloaded", + "description": "Casual First experience in Dont Starve Together\n\nGuest:\nhttps://twitch.tv/axoloaded\n\nStream on 28. July 2024 - - Watch Live on Twitch https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/BVMoml3FQRs/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/BVMoml3FQRs/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/BVMoml3FQRs/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/BVMoml3FQRs/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/BVMoml3FQRs/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 305, + "resourceId": { + "kind": "youtube#video", + "videoId": "BVMoml3FQRs" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "BVMoml3FQRs", + "videoPublishedAt": "2024-08-08T10:19:23Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "ZQZC622MLSCqZU5xflDukxEnMxg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlBHVkU3bjNEdnlF", + "snippet": { + "publishedAt": "2024-08-08T09:02:28Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] The Whistleblower! Why am I doing that to myself again? with @annameison as my support animal", + "description": "First Playthrough of Outlast Whistleblower\n\nGuest: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison)\n\nStream on 26. July 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/PGVE7n3DvyE/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/PGVE7n3DvyE/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/PGVE7n3DvyE/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/PGVE7n3DvyE/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/PGVE7n3DvyE/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 306, + "resourceId": { + "kind": "youtube#video", + "videoId": "PGVE7n3DvyE" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "PGVE7n3DvyE", + "videoPublishedAt": "2024-08-08T09:02:28Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "edzjIL6GPtPIVTnm0KLDvibpfvg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjhVclo0VkZvcnI4", + "snippet": { + "publishedAt": "2024-07-29T14:25:14Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] Bringt den RUM!!! Wir stechen erneut in See", + "description": "Casual Playthrough of Assassins Creed Black Flag \n\nStream on 24. July 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/8UrZ4VForr8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/8UrZ4VForr8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/8UrZ4VForr8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/8UrZ4VForr8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/8UrZ4VForr8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 307, + "resourceId": { + "kind": "youtube#video", + "videoId": "8UrZ4VForr8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "8UrZ4VForr8", + "videoPublishedAt": "2024-07-29T14:25:14Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "aXI9l4fc57kSa-YBRLmKk2AsrNs", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkRtZlNhc1Q2bGtn", + "snippet": { + "publishedAt": "2024-07-25T14:57:06Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Being a Meta-Human with @luminere_flash and @thatfewpeople [Part2]", + "description": "Casual OnceHuman Gameplay \n\nGuest: \n\n[https://twitch.tv/luminere_flash](https://twitch.tv/luminere_flash) \n\nStream on 21. July 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/DmfSasT6lkg/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/DmfSasT6lkg/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/DmfSasT6lkg/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/DmfSasT6lkg/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/DmfSasT6lkg/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 308, + "resourceId": { + "kind": "youtube#video", + "videoId": "DmfSasT6lkg" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "DmfSasT6lkg", + "videoPublishedAt": "2024-07-25T14:57:06Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "mBPr7SnclWJ7UsKeleGmBqHfZWA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkJDbDBlSno0TUxF", + "snippet": { + "publishedAt": "2024-07-25T10:33:26Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Finishing Outlast, Continuing with Minecraft and OnceHuman [Part1]", + "description": "First Playthrough of Outlast\n\nContinuing with Minecraft and OnceHuman \n\nGuest: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\n[https://twitch.tv/luminere_flash](https://twitch.tv/luminere_flash) \n\nStream on 21. July 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/BCl0eJz4MLE/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/BCl0eJz4MLE/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/BCl0eJz4MLE/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/BCl0eJz4MLE/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/BCl0eJz4MLE/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 309, + "resourceId": { + "kind": "youtube#video", + "videoId": "BCl0eJz4MLE" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "BCl0eJz4MLE", + "videoPublishedAt": "2024-07-25T10:33:26Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Pk8vtr2ePQqfAKvnybM86enHS2k", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmE4elJIZEpfTXY4", + "snippet": { + "publishedAt": "2024-07-24T07:46:12Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Horror weekend is continuing! Some evil is within.", + "description": "Casual The Evil Within Playthrough (second playthrough) \n\nStream on 20. July 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/a8zRHdJ_Mv8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/a8zRHdJ_Mv8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/a8zRHdJ_Mv8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/a8zRHdJ_Mv8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/a8zRHdJ_Mv8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 310, + "resourceId": { + "kind": "youtube#video", + "videoId": "a8zRHdJ_Mv8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "a8zRHdJ_Mv8", + "videoPublishedAt": "2024-07-24T07:46:12Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "TXvk2hlucV5WHh6HMeD4nrgxgYY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnB6dVdqb3FxVE04", + "snippet": { + "publishedAt": "2024-07-22T07:56:01Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "unpublished - game crashes in Outlast / PC Crash", + "description": "Broadcasted live on Twitch -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/pzuWjoqqTM8/default.jpg?sqp=CJSIwdAG&rs=AOn4CLByiyJFemibdnMr0y4DFipWq-WRIg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/pzuWjoqqTM8/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBS1dqAHdBoWSJN2z35xWLa6l5MXw", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/pzuWjoqqTM8/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDLCjAlXWqsziqet6tWinKVGuL8ow", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/pzuWjoqqTM8/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCsKB6WJHzunD9gMLnP39jyz1NZTg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/pzuWjoqqTM8/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLAFTx840ZtyyH0Y2f543XbxFWhPLQ", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 311, + "resourceId": { + "kind": "youtube#video", + "videoId": "pzuWjoqqTM8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "pzuWjoqqTM8", + "videoPublishedAt": "2024-07-22T07:56:01Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "nVvdeArgNyQQBwboD0C7SKGsysI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmdUZVZLS3EwTVlV", + "snippet": { + "publishedAt": "2024-07-19T12:30:11Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] Piratenzeit! Wer hat den Rum?", + "description": "Casual AC: Black Flag Gameplay \n\nStream vom 17. Juli 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/gTeVKKq0MYU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/gTeVKKq0MYU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/gTeVKKq0MYU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/gTeVKKq0MYU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/gTeVKKq0MYU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 312, + "resourceId": { + "kind": "youtube#video", + "videoId": "gTeVKKq0MYU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "gTeVKKq0MYU", + "videoPublishedAt": "2024-07-19T12:30:11Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "pmcxjs1kVXmI3r7BXcZT-2gvMRY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLm1WcVRHYlM5M3Jj", + "snippet": { + "publishedAt": "2024-07-19T07:48:22Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Once we were Human! | w/ @luminere_flash @thatfewpeople", + "description": "Casual Gameplay of OnceHuman \n\nGuest: \n\n[https://twitch.tv/luminere_flash](https://twitch.tv/luminere_flash) \n\nStream on 16. July 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/mVqTGbS93rc/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/mVqTGbS93rc/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/mVqTGbS93rc/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/mVqTGbS93rc/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/mVqTGbS93rc/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 313, + "resourceId": { + "kind": "youtube#video", + "videoId": "mVqTGbS93rc" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "mVqTGbS93rc", + "videoPublishedAt": "2024-07-19T07:48:22Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "6DkYdELxaQqo_X3hGhFdBI2bT8Q", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkxOT3EtVFRJSjU0", + "snippet": { + "publishedAt": "2024-07-17T08:02:50Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Get The Furry Out - Lets clear some rooms together", + "description": "Casual GTFO Gameplay \n\nFollowed by Minecraft ATM9 To the Skies \n\nGuests: \n\n[https://twitch.tv/axoloaded](https://twitch.tv/axoloaded) \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\n[https://twitch.tv/mooseman06](https://twitch.tv/mooseman06) \n\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman) \n\nStream on 14. July 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/LNOq-TTIJ54/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/LNOq-TTIJ54/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/LNOq-TTIJ54/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/LNOq-TTIJ54/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/LNOq-TTIJ54/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 314, + "resourceId": { + "kind": "youtube#video", + "videoId": "LNOq-TTIJ54" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "LNOq-TTIJ54", + "videoPublishedAt": "2024-07-17T08:02:50Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "9qY__jrKF3peWfcogHEIEgC5s3E", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjhWM3hUTmFBdmJF", + "snippet": { + "publishedAt": "2024-07-17T06:53:21Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Putting the company into bankruptcy | w/ @annameison @axoloaded", + "description": "Casual Lethal Company Gameplay \n\nFollowed by OnceHuman \n\nGuests: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\n[https://twitch.tv/axoloaded](https://twitch.tv/axoloaded) \n\n[https://twitch.tv/rubenhue](https://twitch.tv/rubenhue) \n\nStream on -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/8V3xTNaAvbE/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/8V3xTNaAvbE/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/8V3xTNaAvbE/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/8V3xTNaAvbE/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/8V3xTNaAvbE/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 315, + "resourceId": { + "kind": "youtube#video", + "videoId": "8V3xTNaAvbE" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "8V3xTNaAvbE", + "videoPublishedAt": "2024-07-17T06:53:21Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "FRd8k6pIY-vwzdOBYunyrFv2kKA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjhHUWlndEFOMm9V", + "snippet": { + "publishedAt": "2024-07-14T19:50:04Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] I only understand trainstation | w/ @annameison @scarofman", + "description": "Casual chaotic Satisfactory gameplay \n\nGuest: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman) \n\nStream on 12. July 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/8GQigtAN2oU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/8GQigtAN2oU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/8GQigtAN2oU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/8GQigtAN2oU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/8GQigtAN2oU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 316, + "resourceId": { + "kind": "youtube#video", + "videoId": "8GQigtAN2oU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "8GQigtAN2oU", + "videoPublishedAt": "2024-07-14T19:50:04Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "KGfseXWpm7bP-5t8-g7y0-IV7es", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjFudmpSZVlXeFQ0", + "snippet": { + "publishedAt": "2024-07-14T11:22:41Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Once we were Human, now we are a Meta-Human!", + "description": "Casual OnceHuman Gameplay \n\nStream on 10. July 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/1nvjReYWxT4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/1nvjReYWxT4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/1nvjReYWxT4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/1nvjReYWxT4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/1nvjReYWxT4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 317, + "resourceId": { + "kind": "youtube#video", + "videoId": "1nvjReYWxT4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "1nvjReYWxT4", + "videoPublishedAt": "2024-07-14T11:22:41Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "AxtZgj1R4x1S8AGC7G1j4x822I4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlhKU3JPOWppY3BR", + "snippet": { + "publishedAt": "2024-07-07T21:32:11Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Get The Furry Out! or something... We will just do the die together part | w/ @annameison @axo", + "description": "First impressions on the game and pushing forward. \n\nLearning the difficult game that is GTFO \n\nGuest: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\n[https://twitch.tv/axoloaded](https://twitch.tv/axoloaded) \n\n[https://twitch.tv/mooseman06](https://twitch.tv/mooseman06) \n\n -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/XJSrO9jicpQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/XJSrO9jicpQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/XJSrO9jicpQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/XJSrO9jicpQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/XJSrO9jicpQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 318, + "resourceId": { + "kind": "youtube#video", + "videoId": "XJSrO9jicpQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "XJSrO9jicpQ", + "videoPublishedAt": "2024-07-07T21:32:11Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "COltJDcUOOokG9WllLgr8HWETHI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlZNU0lPbHJsdFI0", + "snippet": { + "publishedAt": "2024-07-07T20:37:56Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Satisfying factories where never the plan | w/ @annameison", + "description": "Casual chaotic Satisfactory gameplay \n\nGuest: \n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\n-- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/VMSIOlrltR4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/VMSIOlrltR4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/VMSIOlrltR4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/VMSIOlrltR4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/VMSIOlrltR4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 319, + "resourceId": { + "kind": "youtube#video", + "videoId": "VMSIOlrltR4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "VMSIOlrltR4", + "videoPublishedAt": "2024-07-07T20:37:56Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "E8RCS_lNap_UHmZk0TUH6o2zws4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkQwUTRfaDJrbEI4", + "snippet": { + "publishedAt": "2024-07-07T18:38:09Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] ATM9 To the Sky | surviving on some dirt and a tree | Server by Halbpixel.de", + "description": "Casual Minecraft ATM 9 To the Skies modpack Gameplay \n\nStream on 5. July 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/D0Q4_h2klB8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/D0Q4_h2klB8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/D0Q4_h2klB8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/D0Q4_h2klB8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/D0Q4_h2klB8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 320, + "resourceId": { + "kind": "youtube#video", + "videoId": "D0Q4_h2klB8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "D0Q4_h2klB8", + "videoPublishedAt": "2024-07-07T18:38:09Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "dU9gfRymo4nh17zCseq6fXR2feQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlFxZnNCMjBVN2pv", + "snippet": { + "publishedAt": "2024-07-07T00:07:14Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Getting stuck in the mud, flipping trucks and probably loosing some cargo | w/ @scarofman", + "description": "Casual Snowrunner gameplay \n\nGuest:\nhttps://twitch.tv/scarofman\n\nStream on June 25. 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/QqfsB20U7jo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/QqfsB20U7jo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/QqfsB20U7jo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/QqfsB20U7jo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/QqfsB20U7jo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 321, + "resourceId": { + "kind": "youtube#video", + "videoId": "QqfsB20U7jo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "QqfsB20U7jo", + "videoPublishedAt": "2024-07-07T00:07:14Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "5RPfHBj0j1R0EAvJHTck3e0ZtZc", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmFfbG9fOU9fR2pR", + "snippet": { + "publishedAt": "2024-07-06T17:08:13Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] The Devil Within", + "description": "Casual The Evil Within playthrough \n\nStream on 23. June 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/a_lo_9O_GjQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/a_lo_9O_GjQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/a_lo_9O_GjQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/a_lo_9O_GjQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/a_lo_9O_GjQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 322, + "resourceId": { + "kind": "youtube#video", + "videoId": "a_lo_9O_GjQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "a_lo_9O_GjQ", + "videoPublishedAt": "2024-07-06T17:08:13Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "kPgN4zHm2fFDeMdT0YtUrTJceyM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnVzZ2JvcHllUWpz", + "snippet": { + "publishedAt": "2024-07-06T15:04:02Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Factory satisfaction is the plan. But even the best plan only lives to enemy contact, right?", + "description": "Casual satisfactory gameplay \n\nGuest: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/usgbopyeQjs/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/usgbopyeQjs/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/usgbopyeQjs/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/usgbopyeQjs/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/usgbopyeQjs/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 323, + "resourceId": { + "kind": "youtube#video", + "videoId": "usgbopyeQjs" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "usgbopyeQjs", + "videoPublishedAt": "2024-07-06T15:04:02Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "FMOMBmBLNb38TE-qvf0nTs26nGM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmduOXRZOXdDMm9z", + "snippet": { + "publishedAt": "2024-07-04T20:01:03Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] Sehr akurate Schiffahrt", + "description": "Gemütliches Playthrough von Assassins Creed Black Flag \n\nStream am 19. Juni 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/gn9tY9wC2os/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/gn9tY9wC2os/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/gn9tY9wC2os/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/gn9tY9wC2os/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/gn9tY9wC2os/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 324, + "resourceId": { + "kind": "youtube#video", + "videoId": "gn9tY9wC2os" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "gn9tY9wC2os", + "videoPublishedAt": "2024-07-04T20:01:03Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "9Cx2TYBcKLSPo9jsSSwP8xuS-Rk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlJUc0p1TFd1YkN3", + "snippet": { + "publishedAt": "2024-07-04T18:13:55Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Satisfying the factory with @annameison", + "description": "Casual Satisfactory Gameplay \n\nGuest: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\nStream on 16. June 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/RTsJuLWubCw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/RTsJuLWubCw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/RTsJuLWubCw/hqdefault.jpg", + "width": 480, + "height": 360 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 325, + "resourceId": { + "kind": "youtube#video", + "videoId": "RTsJuLWubCw" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "RTsJuLWubCw", + "videoPublishedAt": "2024-07-04T18:13:55Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "ChZrimWTra7y4dnaljG5ybQ4fSU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLldjS2JOa0kzTks0", + "snippet": { + "publishedAt": "2024-06-14T22:37:38Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] We face The Evil Within again!", + "description": "Second Playthrough of The Evil Within \n\nStream on June 14. 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/WcKbNkI3NK4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/WcKbNkI3NK4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/WcKbNkI3NK4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/WcKbNkI3NK4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/WcKbNkI3NK4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 326, + "resourceId": { + "kind": "youtube#video", + "videoId": "WcKbNkI3NK4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "WcKbNkI3NK4", + "videoPublishedAt": "2024-06-14T22:37:38Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Wu20DwDAhdhzrJiBSH52LTQDCDU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnBTeXRCMEdtWVJ3", + "snippet": { + "publishedAt": "2024-06-11T09:00:09Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Getting shot to pieces in Valorant | w/ @annameison @cyc1z", + "description": "Casual Valorant Gameplay \n\nGuests: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\n[https://twitch.tv/cyc1z](https://twitch.tv/cyc1z) \n\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman) -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/pSytB0GmYRw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/pSytB0GmYRw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/pSytB0GmYRw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/pSytB0GmYRw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/pSytB0GmYRw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 327, + "resourceId": { + "kind": "youtube#video", + "videoId": "pSytB0GmYRw" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "pSytB0GmYRw", + "videoPublishedAt": "2024-06-11T09:00:09Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "hIf3lIA21pXQi-YGazbjNysKEgw", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjN5cHQzNjQyQkxn", + "snippet": { + "publishedAt": "2024-06-10T17:11:33Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Finishing Hellblade Senuas Sacrifice | Valorant with @cyc1z @annameison", + "description": "Finishing the Playthrough of Hellblade Senuas Sacrifice \n\nConinuing with Valorant at 01:48:00 \n\nGuests: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\n[https://twitch.tv/cyc1z](https://twitch.tv/cyc1z) \n\nStream on 09. June 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/3ypt3642BLg/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/3ypt3642BLg/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/3ypt3642BLg/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/3ypt3642BLg/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/3ypt3642BLg/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 328, + "resourceId": { + "kind": "youtube#video", + "videoId": "3ypt3642BLg" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "3ypt3642BLg", + "videoPublishedAt": "2024-06-10T17:11:33Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "zvodJcRK03QKHcJgTXXwC-FZFnI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmNRMEZMYi1fbngw", + "snippet": { + "publishedAt": "2024-06-10T12:30:02Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Fuel? Trains? something else? CHAOS in the factory with @annameison", + "description": "Chaotic Satisfactory gameplay \n\nGuest: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\nStream on 07 June 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/cQ0FLb-_nx0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/cQ0FLb-_nx0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/cQ0FLb-_nx0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/cQ0FLb-_nx0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/cQ0FLb-_nx0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 329, + "resourceId": { + "kind": "youtube#video", + "videoId": "cQ0FLb-_nx0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "cQ0FLb-_nx0", + "videoPublishedAt": "2024-06-10T12:30:02Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "F-zaFQuPliGB-HhMPvKTL5i6DY8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk1HZFY2R1VnTWtN", + "snippet": { + "publishedAt": "2024-06-07T09:31:33Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] Weiter gehts mit den Abenteuern auf See!", + "description": "Casual Playthrough of AC: Black Flag \n\nStream on 05. June 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/MGdV6GUgMkM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/MGdV6GUgMkM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/MGdV6GUgMkM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/MGdV6GUgMkM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/MGdV6GUgMkM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 330, + "resourceId": { + "kind": "youtube#video", + "videoId": "MGdV6GUgMkM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "MGdV6GUgMkM", + "videoPublishedAt": "2024-06-07T09:31:33Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "HBXODEbHEaeO7K-ufmxETE-Yn4M", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmUxT3ZIaXFKVl9R", + "snippet": { + "publishedAt": "2024-06-06T08:56:58Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Pipeline-Chaos in Satisfactory with @annameison", + "description": "Chaotic Satisfactory Gameplay \n\nGuest: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\nStream on 02. June 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/e1OvHiqJV_Q/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/e1OvHiqJV_Q/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/e1OvHiqJV_Q/hqdefault.jpg", + "width": 480, + "height": 360 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 331, + "resourceId": { + "kind": "youtube#video", + "videoId": "e1OvHiqJV_Q" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "e1OvHiqJV_Q", + "videoPublishedAt": "2024-06-06T08:56:58Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Zzw8SDnFx2-bS-RfYJRK5sy422M", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmtYS0luSDJxenRv", + "snippet": { + "publishedAt": "2024-06-03T08:22:51Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] The journey continues | Hellblade Senuas Sacrifice", + "description": "First Playthrough of Hellblade Senuas Sacrifice \n\nStream on 31. May 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/kXKInH2qzto/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/kXKInH2qzto/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/kXKInH2qzto/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/kXKInH2qzto/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/kXKInH2qzto/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 332, + "resourceId": { + "kind": "youtube#video", + "videoId": "kXKInH2qzto" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "kXKInH2qzto", + "videoPublishedAt": "2024-06-03T08:22:51Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "jOY7-SOo-myo_0H5738ofslS-vc", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkFUazFna3JmRVdj", + "snippet": { + "publishedAt": "2024-06-02T21:45:09Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] 24h Stream on March 9. 2024 | Part 7 | GarticPhone with @scarofman1426 and more", + "description": "Part 7 of the 24 hour Stream. Chaotic GarticPhone with friends\n\nGuests: \nhttps://twitch.tv/scarofman\nhttps://twitch.tv/axoloaded\nhttps://twitch.tv/jarothorn\nhttps://twitch.tv/annameison\nhttps://twitch.tv/mooseman06\n\nStream on 09. March 2024 - - watch live on twitch: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/ATk1gkrfEWc/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/ATk1gkrfEWc/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/ATk1gkrfEWc/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/ATk1gkrfEWc/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/ATk1gkrfEWc/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 333, + "resourceId": { + "kind": "youtube#video", + "videoId": "ATk1gkrfEWc" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "ATk1gkrfEWc", + "videoPublishedAt": "2024-06-02T21:45:09Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "08eXZ50cRMPM7IJAXSLa-8Tpo-0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmRYSEJmckFsZ2Q4", + "snippet": { + "publishedAt": "2024-06-02T21:38:54Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] 24h Stream on March 9. 2024 | Part 6 | Helldivers with @scarofman1426 and more", + "description": "Part 1 of the 24 hour Stream. Casual Chaotic Satisfactory Gameplay\n\nGuest: \nhttps://twitch.tv/scarofman\nhttps://twitch.tv/mooseman06\nhttps://twitch.tv/axoloaded\n\nStream on 09. March 2024 - - watch live on twitch: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/dXHBfrAlgd8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/dXHBfrAlgd8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/dXHBfrAlgd8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/dXHBfrAlgd8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/dXHBfrAlgd8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 334, + "resourceId": { + "kind": "youtube#video", + "videoId": "dXHBfrAlgd8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "dXHBfrAlgd8", + "videoPublishedAt": "2024-06-02T21:38:54Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "kXlbwe2S621dS_6Le6Zefmclh9E", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjFhczBCREY5UVdF", + "snippet": { + "publishedAt": "2024-06-02T16:33:56Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] 24h Stream on March 9. 2024 | Part 5 | VRChat with @scarofman1426 / @Axoloaded", + "description": "Part 5 of the 24 hour Stream. Casual VRChat Gameplay\n\nGuest: \nhttps://twitch.tv/scarofman\nhttps://twitch.tv/axoloaded\n\nStream on 09. March 2024 - - watch live on twitch: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/1as0BDF9QWE/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/1as0BDF9QWE/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/1as0BDF9QWE/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/1as0BDF9QWE/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/1as0BDF9QWE/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 335, + "resourceId": { + "kind": "youtube#video", + "videoId": "1as0BDF9QWE" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "1as0BDF9QWE", + "videoPublishedAt": "2024-06-02T16:33:56Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "S5IpyY2M_Xg9NJ4gKFiCdFon_gQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnpMcjU3OF9GRDR3", + "snippet": { + "publishedAt": "2024-06-02T15:42:41Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] 24h Stream on March 9. 2024 | Part 4 | Snowrunner with @scarofman1426", + "description": "Part 4 of the 24 hour Stream. Casual Snowrunner Gameplay\n\nGuest: https://twitch.tv/scarofman\n\nStream on 09. March 2024 - - watch live on twitch: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/zLr578_FD4w/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/zLr578_FD4w/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/zLr578_FD4w/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/zLr578_FD4w/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/zLr578_FD4w/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 336, + "resourceId": { + "kind": "youtube#video", + "videoId": "zLr578_FD4w" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "zLr578_FD4w", + "videoPublishedAt": "2024-06-02T15:42:41Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "4LWWq8L9zAPUYIKkrrjSEyosDZs", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjJIQ3NndDR1bTVj", + "snippet": { + "publishedAt": "2024-06-02T15:34:56Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] 24h Stream on March 9. 2024 | Part 3 | Deep Rock Galactic with @scarofman & @makuta87", + "description": "Part 2 of my 24 hour Stream\nCasual World War Z Gameplay\n\nGuests:\nhttps://twitch.tv/scarofman\nhttps://twitch.tv/makuta87\n\nStream on 09. March 2024 - - watch live on Twitch: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/2HCsgt4um5c/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/2HCsgt4um5c/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/2HCsgt4um5c/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/2HCsgt4um5c/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/2HCsgt4um5c/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 337, + "resourceId": { + "kind": "youtube#video", + "videoId": "2HCsgt4um5c" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "2HCsgt4um5c", + "videoPublishedAt": "2024-06-02T15:34:56Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "GLZSoKfnZxfw5L1XNtz1ClC5ezQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjFoeWJqY2U3Z0FN", + "snippet": { + "publishedAt": "2024-06-02T15:10:34Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] 24h Stream on March 9. 2024 | Part 2 | World War Z with @scarofman & @brutesx", + "description": "Part 2 of my 24 hour Stream\nCasual World War Z Gameplay\n\nGuests:\nhttps://twitch.tv/scarofman\nhttps://twitch.tv/brutesx\n\nStream on 09. March 2024 - - watch live on Twitch: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/1hybjce7gAM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/1hybjce7gAM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/1hybjce7gAM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/1hybjce7gAM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/1hybjce7gAM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 338, + "resourceId": { + "kind": "youtube#video", + "videoId": "1hybjce7gAM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "1hybjce7gAM", + "videoPublishedAt": "2024-06-02T15:10:34Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "8cQ9m-BXdbaih3lbmz3H-bsqXr0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjhOc3NpMmdldEg4", + "snippet": { + "publishedAt": "2024-06-02T14:53:59Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] 24h Stream on March 9. 2024 | Part 1 | Satisfactory with @scarofman1426", + "description": "Part 1 of the 24 hour Stream. Casual Chaotic Satisfactory Gameplay\n\nGuest: https://twitch.tv/scarofman\n\n\nStream on 09. March 2024 - - watch live on twitch: https://twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/8Nssi2getH8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/8Nssi2getH8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/8Nssi2getH8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/8Nssi2getH8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/8Nssi2getH8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 339, + "resourceId": { + "kind": "youtube#video", + "videoId": "8Nssi2getH8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "8Nssi2getH8", + "videoPublishedAt": "2024-06-02T14:53:59Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "RdJZPSKZZ_jJpQA-oj_VT1SQp5Q", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkdxaDd4TUFhWTlr", + "snippet": { + "publishedAt": "2024-06-02T14:21:19Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] Die See ruft! Lasset uns Templer und Spanier jagen!", + "description": "Casual AC: Black Flag playthrough \n\nStream on 29. May 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Gqh7xMAaY9k/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Gqh7xMAaY9k/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Gqh7xMAaY9k/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Gqh7xMAaY9k/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Gqh7xMAaY9k/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 340, + "resourceId": { + "kind": "youtube#video", + "videoId": "Gqh7xMAaY9k" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Gqh7xMAaY9k", + "videoPublishedAt": "2024-06-02T14:21:19Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "ozhb8k2f8YWZ0QHRqHagCBonLs4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjE4aVA3NURJbUZr", + "snippet": { + "publishedAt": "2024-05-31T06:12:26Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] More chaos being produced with @annameison and more guests", + "description": "Chatting and Chill Satisfactory Gameplay \n\nGuests: \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\n[https://twitch.tv/maverickavali](https://twitch.tv/maverickavali) \n\nStream on 27. May 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/18iP75DImFk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/18iP75DImFk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/18iP75DImFk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/18iP75DImFk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/18iP75DImFk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 341, + "resourceId": { + "kind": "youtube#video", + "videoId": "18iP75DImFk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "18iP75DImFk", + "videoPublishedAt": "2024-05-31T06:12:26Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "NxxoESeCPT8BoxMWQMSu9_o_QGA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnhLUF9tMUdfLUVV", + "snippet": { + "publishedAt": "2024-05-31T04:47:19Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] A new adventure awaits - Hellblade Senuas Sacrifice", + "description": "Start of the first Playthrough of Hellblade Senuas Sacrifice \n\nStream on 25. May 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/xKP_m1G_-EU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/xKP_m1G_-EU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/xKP_m1G_-EU/hqdefault.jpg", + "width": 480, + "height": 360 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 342, + "resourceId": { + "kind": "youtube#video", + "videoId": "xKP_m1G_-EU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "xKP_m1G_-EU", + "videoPublishedAt": "2024-05-31T04:47:19Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Gd4chB2gYxuNz5Udsc5M5xf4kgI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLm9TTWxTem5UMmdJ", + "snippet": { + "publishedAt": "2024-05-31T03:44:32Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] working on the factory and producing chaos with @annameison", + "description": "Casual Satisfactory Gameplay \n\nGuest: [https://twitch.tv/annameison](https://twitch.tv/annameison) \n\nStream on 24. May 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/oSMlSznT2gI/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/oSMlSznT2gI/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/oSMlSznT2gI/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/oSMlSznT2gI/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/oSMlSznT2gI/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 343, + "resourceId": { + "kind": "youtube#video", + "videoId": "oSMlSznT2gI" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "oSMlSznT2gI", + "videoPublishedAt": "2024-05-31T03:44:32Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "PMKxwOXLjvrEBJgiTtBbh9TBiMw", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnY5NkpUNy1rdlZv", + "snippet": { + "publishedAt": "2024-05-31T03:22:09Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] working on the factory - Satisfactory Chaos", + "description": "Casual Satisfactory Gameplay \n\nStream on 22. May 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/v96JT7-kvVo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/v96JT7-kvVo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/v96JT7-kvVo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/v96JT7-kvVo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/v96JT7-kvVo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 344, + "resourceId": { + "kind": "youtube#video", + "videoId": "v96JT7-kvVo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "v96JT7-kvVo", + "videoPublishedAt": "2024-05-31T03:22:09Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "CMc-oyWhpWeZkM3n8iHpnef0gus", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkJfNC1NMXlaX0JJ", + "snippet": { + "publishedAt": "2024-05-29T07:00:20Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] producing chaos again | w/ @annameison @scarofman", + "description": "Chaotic Satisfactory Gameplay \n\nGuests: \n\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman) \n\n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n\nStream on 19. May 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/B_4-M1yZ_BI/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/B_4-M1yZ_BI/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/B_4-M1yZ_BI/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/B_4-M1yZ_BI/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/B_4-M1yZ_BI/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 345, + "resourceId": { + "kind": "youtube#video", + "videoId": "B_4-M1yZ_BI" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "B_4-M1yZ_BI", + "videoPublishedAt": "2024-05-29T07:00:20Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "iBzOIpwHvP6Vo7mmJiMVx_icmtw", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnVyUTdJTVdtT1hz", + "snippet": { + "publishedAt": "2024-05-29T06:37:38Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Multiple Games Stream - 12 Hour Stream | w/ @axoloaded @jarothorn @mooseman06 @annameison @sca", + "description": "Casual chaos gameplay of \n\n00:00:00 - Flashing Lights \n03:25:00 - Overwatch 2 \n04:40:00 - Minecraft \n07:25:00 - Stream Takeover by @scarofman - Space Engineers \n08:26:00 - Download Break - Wordle / Contexto \n09:00:00 - Space Engineers \n10:32:00 - Snowrunner \n\nGuests: \n\n[https://twitch.tv/axoloaded](https://twitch.tv/axoloaded) \n[https://twitch.tv/annameison](https://twitch.tv/annameison) \n[https://twitch.tv/jarothorn](https://twitch.tv/jarothorn) \n[https://twitch.tv/mooseman06](https://twitch.tv/mooseman06) \n[https://twitch.tv/scarofman](https://twitch.tv/scarofman) \n\nStream on 18. May 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/urQ7IMWmOXs/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/urQ7IMWmOXs/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/urQ7IMWmOXs/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/urQ7IMWmOXs/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/urQ7IMWmOXs/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 346, + "resourceId": { + "kind": "youtube#video", + "videoId": "urQ7IMWmOXs" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "urQ7IMWmOXs", + "videoPublishedAt": "2024-05-29T06:37:38Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "RAbKIEHARRjO0RaZlF4b-LVSzD0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkgySFNqM3lYVFdr", + "snippet": { + "publishedAt": "2024-05-28T21:08:01Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] Wir stechen erneut in See", + "description": "Casual Playthrough von Assassinscreed Black Flag \n\nStream vom 15. Mai 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/H2HSj3yXTWk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/H2HSj3yXTWk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/H2HSj3yXTWk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/H2HSj3yXTWk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/H2HSj3yXTWk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 347, + "resourceId": { + "kind": "youtube#video", + "videoId": "H2HSj3yXTWk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "H2HSj3yXTWk", + "videoPublishedAt": "2024-05-28T21:08:01Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "T8R9nCclk57j4sFS0h8I1vUplUk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLllhNHNrYy1pQWpn", + "snippet": { + "publishedAt": "2024-05-28T18:01:54Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Metal Hellsinger - see if I can do it this time", + "description": "Casual Metal: Hellsinger Playthrough \n\nStream on 13. May 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Ya4skc-iAjg/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Ya4skc-iAjg/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Ya4skc-iAjg/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Ya4skc-iAjg/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Ya4skc-iAjg/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 348, + "resourceId": { + "kind": "youtube#video", + "videoId": "Ya4skc-iAjg" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Ya4skc-iAjg", + "videoPublishedAt": "2024-05-28T18:01:54Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "pBO1-uJzlV0qJwlNK4zYzeWOWBE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmpMWFBuenhGX2pV", + "snippet": { + "publishedAt": "2024-05-27T11:58:40Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Mass producing stupidity with @annameison", + "description": "Casual Chaos Gameplay in Satisfactory \n\nGuest: [https://twitch.tv/annameison](https://twitch.tv/annameison) \n\nStream on 05. May 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/jLXPnzxF_jU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/jLXPnzxF_jU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/jLXPnzxF_jU/hqdefault.jpg", + "width": 480, + "height": 360 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 349, + "resourceId": { + "kind": "youtube#video", + "videoId": "jLXPnzxF_jU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "jLXPnzxF_jU", + "videoPublishedAt": "2024-05-27T11:58:40Z" + }, + "status": { + "privacyStatus": "public" + } + } + ], + "pageInfo": { + "totalResults": 405, + "resultsPerPage": 50 + } + }, + { + "kind": "youtube#playlistItemListResponse", + "etag": "aIVf1QedCN9VSNzHeTUobFrmNdQ", + "nextPageToken": "EAAaB1BUOkNKQUQ", + "prevPageToken": "EAEaB1BUOkNONEM", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "bfvJIXa-H7dIB9GIIs3oZEHzeEY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLi1RZ0VXQURjZjhn", + "snippet": { + "publishedAt": "2024-05-27T08:43:02Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] Space Ninja Training! Heute sehen wir wie gut @makuta87 als Lehrer ist 😜", + "description": "Casual Warframe Gameplay mit Makuta \n\nGuest: [https://twitch.tv/makuta87](https://twitch.tv/makuta87) \n\nStream vom 03. May 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/-QgEWADcf8g/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/-QgEWADcf8g/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/-QgEWADcf8g/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/-QgEWADcf8g/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/-QgEWADcf8g/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 350, + "resourceId": { + "kind": "youtube#video", + "videoId": "-QgEWADcf8g" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "-QgEWADcf8g", + "videoPublishedAt": "2024-05-27T08:43:02Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "CLRVXchF5vaBzvTp7-3c5P8Z0i0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLllzS3hjNWhERlJF", + "snippet": { + "publishedAt": "2024-05-27T07:56:21Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Left 4 Dead 2 with the mates / Once Human with friends", + "description": "Casual Left 4 Dead 2 Gameplay \n\nContinued by Once Human at 02:47:00 \n\nguests: \n\n[https://twitch.tv/axoloaded](https://twitch.tv/axoloaded) \n\n[https://twitch.tv/mooseman06](https://twitch.tv/mooseman06) \n\nStream on 01. May 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/YsKxc5hDFRE/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/YsKxc5hDFRE/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/YsKxc5hDFRE/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/YsKxc5hDFRE/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/YsKxc5hDFRE/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 351, + "resourceId": { + "kind": "youtube#video", + "videoId": "YsKxc5hDFRE" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "YsKxc5hDFRE", + "videoPublishedAt": "2024-05-27T07:56:21Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "zVH56dMEvnqcitWbwYKQ3ZDUFf4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk0zWkJhNHdkMlZn", + "snippet": { + "publishedAt": "2024-05-27T06:31:48Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Doronko Wanko / Hellpie", + "description": "Casual playthrough of Doronko Wanko \n\nContinued by Hellpie at 1:05:00 \n\nStream on 30. April 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/M3ZBa4wd2Vg/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/M3ZBa4wd2Vg/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/M3ZBa4wd2Vg/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/M3ZBa4wd2Vg/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/M3ZBa4wd2Vg/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 352, + "resourceId": { + "kind": "youtube#video", + "videoId": "M3ZBa4wd2Vg" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "M3ZBa4wd2Vg", + "videoPublishedAt": "2024-05-27T06:31:48Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "EOQtEexZzvbEzyfK5FonvOKE7Gk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjZkbnp3VDlFUzhJ", + "snippet": { + "publishedAt": "2024-05-22T10:15:58Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Once we were human! Bringing him up to speed! | w/ @axoloaded", + "description": "Casual Once Human Gameplay \n\nGuest: [https://twitch.tv/axoloaded](https://twitch.tv/axoloaded) \n\nStream on 21. April 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/6dnzwT9ES8I/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/6dnzwT9ES8I/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/6dnzwT9ES8I/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/6dnzwT9ES8I/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/6dnzwT9ES8I/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 353, + "resourceId": { + "kind": "youtube#video", + "videoId": "6dnzwT9ES8I" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "6dnzwT9ES8I", + "videoPublishedAt": "2024-05-22T10:15:58Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "1HqUjc9-v5K0c2EqkAvLQVvdqXk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLl9pRERVX0RWcjY0", + "snippet": { + "publishedAt": "2024-05-22T08:27:41Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Idiots in Trucks in Snowrunner! Brain not included | w/ @brutesx", + "description": "Casual Snowrunner Gameplay \n\nGuest: [https://twitch.tv/brutesx](https://twitch.tv/brutesx) \n\nStream on 20. April 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/_iDDU_DVr64/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/_iDDU_DVr64/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/_iDDU_DVr64/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/_iDDU_DVr64/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/_iDDU_DVr64/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 354, + "resourceId": { + "kind": "youtube#video", + "videoId": "_iDDU_DVr64" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "_iDDU_DVr64", + "videoPublishedAt": "2024-05-22T08:27:41Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "gcDT9jw_vIY4UF1FZKOm41vMdz8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkhBMkxYd0dvQjlF", + "snippet": { + "publishedAt": "2024-05-15T14:30:23Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] Warframe: mein erster Eindruck, mein erstes Gameplay | w/ @mauvt_ @gordy_natortv @makuta", + "description": "Erster Eindruck von Warframe, Tutorial und gameplay \n\nGäste: \n\n[https://twitch.tv/makuta87](https://twitch.tv/makuta87) \n\n[https://twitch.tv/gordy_natortv](https://twitch.tv/gordy_natortv) \n\n[https://twitch.tv/mauvt_](https://twitch.tv/mauvt_) \n\nStream vom 17. April 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/HA2LXwGoB9E/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/HA2LXwGoB9E/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/HA2LXwGoB9E/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/HA2LXwGoB9E/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/HA2LXwGoB9E/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 355, + "resourceId": { + "kind": "youtube#video", + "videoId": "HA2LXwGoB9E" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "HA2LXwGoB9E", + "videoPublishedAt": "2024-05-15T14:30:23Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "QdJIHg2gl1NNC2Pv0-MUKpG4AtA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmg2QUU3TDZReHNZ", + "snippet": { + "publishedAt": "2024-05-15T14:19:39Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] Wir waren einmal menschlich... OnceHuman Closed Beta Gameplay | w/ @scarofman", + "description": "Casual Gameplay der Closed Beta 3 von OnceHuman \n\nGäste: \n\n[https://twitch.tv/scarofman](https://twitch.tv/scarofman) \n\nStream vom 16. April 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/h6AE7L6QxsY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/h6AE7L6QxsY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/h6AE7L6QxsY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/h6AE7L6QxsY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/h6AE7L6QxsY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 356, + "resourceId": { + "kind": "youtube#video", + "videoId": "h6AE7L6QxsY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "h6AE7L6QxsY", + "videoPublishedAt": "2024-05-15T14:19:39Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "-tDbMi0dSiXGOXA2MMpO6POK_5Y", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlhhV3BPWE9SdWJz", + "snippet": { + "publishedAt": "2024-05-15T09:11:12Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] Wir waren einmal menschlich... OnceHuman Closed Beta Gameplay | w/ @zslyrack @gordy_natortv @m", + "description": "Casual Gameplay der Closed Beta 3 von OnceHuman \n\nGäste: \n\n[https://twitch.tv/zslyrack](https://twitch.tv/zslyrack) \n\n[https://twitch.tv/Gordy_NatorTV](https://twitch.tv/Gordy_NatorTV) \n[https://twitch.tv/MauVT_](https://twitch.tv/MauVT_) \n[https://twitch.tv/zslyrack](https://twitch.tv/zslyrack) \n[https://twitch.tv/scarofman](https://twitch.tv/scarofman) \n[https://twitch.tv/Ernstaugust01](https://twitch.tv/Ernstaugust01) \n\nStream vom 14. April 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/XaWpOXORubs/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/XaWpOXORubs/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/XaWpOXORubs/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/XaWpOXORubs/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/XaWpOXORubs/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 357, + "resourceId": { + "kind": "youtube#video", + "videoId": "XaWpOXORubs" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "XaWpOXORubs", + "videoPublishedAt": "2024-05-15T09:11:12Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "lRYl8CNH_j6zL98Oz0uilULoK1A", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnpweExfQi1ta19v", + "snippet": { + "publishedAt": "2024-05-15T07:38:14Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Once we were Human, now we are more. CBT3 of OnceHuman", + "description": "Casual Gameplay of the Closed Beta 3 of OnceHuman \n\nGuest: [https://twitch.tv/scarofman](https://twitch.tv/scarofman) \n\nStream of 10. April 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/zpxL_B-mk_o/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/zpxL_B-mk_o/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/zpxL_B-mk_o/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/zpxL_B-mk_o/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/zpxL_B-mk_o/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 358, + "resourceId": { + "kind": "youtube#video", + "videoId": "zpxL_B-mk_o" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "zpxL_B-mk_o", + "videoPublishedAt": "2024-05-15T07:38:14Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "yu39BfERbjS3aCsmxy63USEQfNU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmhPY2EyZF85UEJz", + "snippet": { + "publishedAt": "2024-05-15T06:29:31Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] Once we were Human, now we are more. CBT3 of OnceHuman | w/ @scarofman", + "description": "Casual Gameplay of the Closed Beta 3 of OnceHuman \n\nGuest: [https://twitch.tv/scarofman](https://twitch.tv/scarofman) \n\nStream of 09. April 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/hOca2d_9PBs/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/hOca2d_9PBs/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/hOca2d_9PBs/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/hOca2d_9PBs/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/hOca2d_9PBs/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 359, + "resourceId": { + "kind": "youtube#video", + "videoId": "hOca2d_9PBs" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "hOca2d_9PBs", + "videoPublishedAt": "2024-05-15T06:29:31Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "t3KBCL_VPvpscPFz6U5n-zMugX0", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjQ0SDc5aWtWMGkw", + "snippet": { + "publishedAt": "2024-05-08T08:54:10Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - spreading democracy with the mates! | w/ @axoloaded @jarothorn @mooseman06 | Continue with O", + "description": "Casual Chaos in Helldivers 2 \n\nOnceHuman at 05:26:00 \n\nGuest: \n\n[https://twitch.tv/axoloaded](https://twitch.tv/axoloaded) \n[https://twitch.tv/jarothorn](https://twitch.tv/jarothorn) \n[https://twitch.tv/mooseman06](https://twitch.tv/mooseman06) \n[https://twitch.tv/goobystrm](https://twitch.tv/goobystrm) \n\nStream on 07. April 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/44H79ikV0i0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/44H79ikV0i0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/44H79ikV0i0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/44H79ikV0i0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/44H79ikV0i0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 360, + "resourceId": { + "kind": "youtube#video", + "videoId": "44H79ikV0i0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "44H79ikV0i0", + "videoPublishedAt": "2024-05-08T08:54:10Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "YBJUOgykNkQvnv9hMTft-fLgMWI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlB3SUdSRkRHR1hV", + "snippet": { + "publishedAt": "2024-05-08T08:22:08Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - Metal: Hellsinger, then OnceHuman | w/ @axoloaded @goobystrm", + "description": "Casual Metal: Hellsinger gameplay \n\nContinue with OnceHuman at 3:00:00 \n\nGuest: \n\n[https://twitch.tv/axoloaded](https://twitch.tv/axoloaded) \n\n[https://twitch.tv/goobystrm](https://twitch.tv/goobystrm) \n\nStream on 05. April 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/PwIGRFDGGXU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/PwIGRFDGGXU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/PwIGRFDGGXU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/PwIGRFDGGXU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/PwIGRFDGGXU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 361, + "resourceId": { + "kind": "youtube#video", + "videoId": "PwIGRFDGGXU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "PwIGRFDGGXU", + "videoPublishedAt": "2024-05-08T08:22:08Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "DOWJRNkIIfnW22PjDY7ySmW40Hk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnJWUmxrR0Utbl9R", + "snippet": { + "publishedAt": "2024-05-02T07:21:45Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] - Weiter gehts zu Wasser und zu Land! Setzen wir die Segel und genießen \"AC: Black Flag\"", + "description": "Casual Playthrough von Assassins Creed Black Flag \n\nStream vom 03. April 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/rVRlkGE-n_Q/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/rVRlkGE-n_Q/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/rVRlkGE-n_Q/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/rVRlkGE-n_Q/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/rVRlkGE-n_Q/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 362, + "resourceId": { + "kind": "youtube#video", + "videoId": "rVRlkGE-n_Q" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "rVRlkGE-n_Q", + "videoPublishedAt": "2024-05-02T07:21:45Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "xvjwALYV3q95FbqqrqYsO_cXI-4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjVKd2pRYkJIVW5B", + "snippet": { + "publishedAt": "2024-05-02T07:12:35Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - Metal Hellsinger | Lightyear Frontier w/ @brutesx", + "description": "Casual Metal Hellsinger gameplay \n\nCasual Lightyear Frontier gameplay with @brutesx \n\nhttps://twitch.tv/brutesx\n\nStream on 30. March 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/5JwjQbBHUnA/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/5JwjQbBHUnA/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/5JwjQbBHUnA/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/5JwjQbBHUnA/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/5JwjQbBHUnA/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 363, + "resourceId": { + "kind": "youtube#video", + "videoId": "5JwjQbBHUnA" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "5JwjQbBHUnA", + "videoPublishedAt": "2024-05-02T07:12:35Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "nWsnHD4m7Y4H7icvJ5RV4i7K3lQ", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjlXcjB5YUszTkFR", + "snippet": { + "publishedAt": "2024-04-24T08:12:31Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - FOR DEMOCRACY! Helldivers with friends w/ @axoloaded @jarothorn | Minecraft w/ @goobystrm", + "description": "Casual Gameplay of Helldivers 2 with @jarothorn and @axoloaded \n\nStarting 06:30:00 with Minecraft with @goobystrm \n\nhttps://twitch.tv/axoloaded\nhttps://twitch.tv/jarothorn\nhttps://twitch.tv/goobystrm\n\nStream 29. March 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/9Wr0yaK3NAQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/9Wr0yaK3NAQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/9Wr0yaK3NAQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/9Wr0yaK3NAQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/9Wr0yaK3NAQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 364, + "resourceId": { + "kind": "youtube#video", + "videoId": "9Wr0yaK3NAQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "9Wr0yaK3NAQ", + "videoPublishedAt": "2024-04-24T08:12:31Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "zGbRWUhbrRxnleswkd43yEe5BZk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLm5CUGJLLU55WVRB", + "snippet": { + "publishedAt": "2024-04-24T07:57:04Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - Mech Farm Game - Two idiots clean up a planet (brain not included) | w/ @brutesx", + "description": "Casual Lightyear Frontier gameplay \n\nhttps://twitch.tv/brutesx\n\nStream 27. March 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/nBPbK-NyYTA/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/nBPbK-NyYTA/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/nBPbK-NyYTA/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/nBPbK-NyYTA/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/nBPbK-NyYTA/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 365, + "resourceId": { + "kind": "youtube#video", + "videoId": "nBPbK-NyYTA" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "nBPbK-NyYTA", + "videoPublishedAt": "2024-04-24T07:57:04Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "j1mimXRlK-anoHp3VamSiDRCDiY", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk13TkZGbXBpS0tV", + "snippet": { + "publishedAt": "2024-04-24T07:17:50Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - The mud returns! Idiots on the road", + "description": "Casual Gameplay of Snowrunner \n\nFollowed by Satisfactory \n\nwith @scarofman \n\nStream 23. March 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/MwNFFmpiKKU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/MwNFFmpiKKU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/MwNFFmpiKKU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/MwNFFmpiKKU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/MwNFFmpiKKU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 366, + "resourceId": { + "kind": "youtube#video", + "videoId": "MwNFFmpiKKU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "MwNFFmpiKKU", + "videoPublishedAt": "2024-04-24T07:17:50Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "Bu0sX9kkP7NPng1-jWA5WeuXD-c", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjF0R0lJYmVFcElN", + "snippet": { + "publishedAt": "2024-04-24T07:13:26Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] - Chaos in den Grenzlanden | w/ @makuta87 @mauvt_", + "description": "Gemütliches Borderlands spielen \n\nhttps://twitch.tv/makuta87\nhttps://twitch.tv/mauvt_\n\nStream vom 25. März 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/1tGIIbeEpIM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/1tGIIbeEpIM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/1tGIIbeEpIM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/1tGIIbeEpIM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/1tGIIbeEpIM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 367, + "resourceId": { + "kind": "youtube#video", + "videoId": "1tGIIbeEpIM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "1tGIIbeEpIM", + "videoPublishedAt": "2024-04-24T07:13:26Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "n8MrnaKWcRRxOtXsh2pRzhC1AZU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLks0aTJ3TC1LY1hr", + "snippet": { + "publishedAt": "2024-04-17T12:32:45Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] - Wir stechen erneut in See", + "description": "Stream vom 13. März 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/K4i2wL-KcXk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/K4i2wL-KcXk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/K4i2wL-KcXk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/K4i2wL-KcXk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/K4i2wL-KcXk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 368, + "resourceId": { + "kind": "youtube#video", + "videoId": "K4i2wL-KcXk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "K4i2wL-KcXk", + "videoPublishedAt": "2024-04-17T12:32:45Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "8jPPM5Dbyo5CLx4UL1fiUnWhuDU", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlpZYUlZdnpRYmU4", + "snippet": { + "publishedAt": "2024-04-17T11:54:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - Playing the masterpiece that is Bramble the Mountain King", + "description": "Full playthrough of Bramble the Mountain King \n\nAfter that (at 04:25:00) casual gameplay of Snowrunner \n\nStream 22. March 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/ZYaIYvzQbe8/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/ZYaIYvzQbe8/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/ZYaIYvzQbe8/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/ZYaIYvzQbe8/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/ZYaIYvzQbe8/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 369, + "resourceId": { + "kind": "youtube#video", + "videoId": "ZYaIYvzQbe8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "ZYaIYvzQbe8", + "videoPublishedAt": "2024-04-17T11:54:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "S_FHrhf7nmRXpdwn5rM42FPETxI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlZZTFA4eHVsSlNR", + "snippet": { + "publishedAt": "2024-04-10T12:10:55Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - The mud returns! Idiots on the road! | w/ @scarofman", + "description": "Casual Snowrunner Stream with @scarofman \n\nhttps://twitch.tv/scarofman\nStream 20. March 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/VYLP8xulJSQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/VYLP8xulJSQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/VYLP8xulJSQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/VYLP8xulJSQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/VYLP8xulJSQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 370, + "resourceId": { + "kind": "youtube#video", + "videoId": "VYLP8xulJSQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "VYLP8xulJSQ", + "videoPublishedAt": "2024-04-10T12:10:55Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "vFmxFqgUIePi5BrcxbF1Q8-7Hdk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkJaTTE3dHp4d0NV", + "snippet": { + "publishedAt": "2024-04-10T08:24:16Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - The mess that calls itslef our factory | Casual Satisfactory | w/ @scarofman", + "description": "Casual Satisfactory Gameplay \n\nhttps://twitch.tv/scarofman\n\nStream 16. March 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/BZM17tzxwCU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/BZM17tzxwCU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/BZM17tzxwCU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/BZM17tzxwCU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/BZM17tzxwCU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 371, + "resourceId": { + "kind": "youtube#video", + "videoId": "BZM17tzxwCU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "BZM17tzxwCU", + "videoPublishedAt": "2024-04-10T08:24:16Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "lmKEwQsptcWAWLQ_y1OM-MuyB_g", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlM5YjJ2dk1ranpZ", + "snippet": { + "publishedAt": "2024-04-10T08:23:50Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - For DEMOCRACY!!! Helldivers 2 with da boys! | w/ @axoloaded @mooseman06 @jarothorn", + "description": "Casual (Chaotic) Gameplay of Helldivers 2 \n\nGuests:\nhttps://twitch.tv/Axoloaded\nhttps://twitch.tv/Mooseman06\nhttps://twitch.tv/Jarothorn\n\nStream 14. March 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/S9b2vvMkjzY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/S9b2vvMkjzY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/S9b2vvMkjzY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/S9b2vvMkjzY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/S9b2vvMkjzY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 372, + "resourceId": { + "kind": "youtube#video", + "videoId": "S9b2vvMkjzY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "S9b2vvMkjzY", + "videoPublishedAt": "2024-04-10T08:23:50Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "i478CauBZdeP3oxE2bg3FED0g20", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnFSWEFFWDNqMGxN", + "snippet": { + "publishedAt": "2024-03-09T14:53:59Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - An Idiots guide to Lethal Company | w/ @scarofman @mauvt_ @gordy_natortv @axoloaded @annamei", + "description": "Chaotic gameplay of Modded Lethal Company \n\nhttps://twitch.tv/scarofman\nhttps://twitch.tv/gordy_natortv\nhttps://twitch.tv/mauvt_\nhttps://twitch.tv/annameison\nhttps://twitch.tv/axoloaded\n\nStream 08. March 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/qRXAEX3j0lM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/qRXAEX3j0lM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/qRXAEX3j0lM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/qRXAEX3j0lM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/qRXAEX3j0lM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 373, + "resourceId": { + "kind": "youtube#video", + "videoId": "qRXAEX3j0lM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "qRXAEX3j0lM", + "videoPublishedAt": "2024-03-09T14:53:59Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "7zp0cBb_RcIL_-Ex-ySwIfHh2sc", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmRXdzltd3FaN3ZR", + "snippet": { + "publishedAt": "2024-03-05T13:44:12Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] - Wir stechen in See! AC: Black Flag", + "description": "Entspanntes Gameplay von AC: Black Flag \n\nStream vom 04. März 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/dWw9mwqZ7vQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/dWw9mwqZ7vQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/dWw9mwqZ7vQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/dWw9mwqZ7vQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/dWw9mwqZ7vQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 374, + "resourceId": { + "kind": "youtube#video", + "videoId": "dWw9mwqZ7vQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "dWw9mwqZ7vQ", + "videoPublishedAt": "2024-03-05T13:44:12Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "sJpIPv8gk9ZmYMJXZpW2mUkwbEw", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlVOS3BZZkJoWno0", + "snippet": { + "publishedAt": "2024-03-01T11:31:58Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] - AC Black Flag Probleme / Snowrunner w/ @ScarOfMan", + "description": "Versuch an AC-BlackFlag, Nur Probleme \n\nSwitch auf Snowrunner mit @ScarOfMan \n\nhttps://twitch.tv/scarofman\n\nStream vom 28. Februar 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/UNKpYfBhZz4/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/UNKpYfBhZz4/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/UNKpYfBhZz4/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/UNKpYfBhZz4/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/UNKpYfBhZz4/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 375, + "resourceId": { + "kind": "youtube#video", + "videoId": "UNKpYfBhZz4" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "UNKpYfBhZz4", + "videoPublishedAt": "2024-03-01T11:31:58Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "fVzbqtaDUNZ1dTSc94_DISs9CTk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkVNYTV5dGxkTS1V", + "snippet": { + "publishedAt": "2024-02-28T08:28:54Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] - Idioten auf der Straße | w/ @scarofman", + "description": "Gemütliches Snowrunner Gameplay mit ScarOfMan \n\nhttps://twitch.tv/scarofman\n\nStream vom 25. Februar 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/EMa5ytldM-U/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/EMa5ytldM-U/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/EMa5ytldM-U/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/EMa5ytldM-U/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/EMa5ytldM-U/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 376, + "resourceId": { + "kind": "youtube#video", + "videoId": "EMa5ytldM-U" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "EMa5ytldM-U", + "videoPublishedAt": "2024-02-28T08:28:54Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "ztdEFEnrNcqR9PD-sbUYF3d9NXo", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLktpWUZmQ3dNWE8w", + "snippet": { + "publishedAt": "2024-02-26T14:08:52Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - continuing my first advanture in Tiny Tinas Wonderlands | w/ @ak_wonderland", + "description": "Casual Playthrough of Tiny Tinas Wonderlands with @ak_wonderland\n\nhttps://twitch.tv/ak_wonderland\n\nStream 24. February -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/KiYFfCwMXO0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/KiYFfCwMXO0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/KiYFfCwMXO0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/KiYFfCwMXO0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/KiYFfCwMXO0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 377, + "resourceId": { + "kind": "youtube#video", + "videoId": "KiYFfCwMXO0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "KiYFfCwMXO0", + "videoPublishedAt": "2024-02-26T14:08:52Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "-DgSKgfYBmkjr0puxanaXx_wXm8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnpuZG4yNm5mcVkw", + "snippet": { + "publishedAt": "2024-02-22T03:54:35Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - starting my first advanture in Tiny Tinas Wonderlands", + "description": "Casual gameplay of Tiny Tinas Wonderlands \n\nStream 21. February 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/zndn26nfqY0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/zndn26nfqY0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/zndn26nfqY0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/zndn26nfqY0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/zndn26nfqY0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 378, + "resourceId": { + "kind": "youtube#video", + "videoId": "zndn26nfqY0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "zndn26nfqY0", + "videoPublishedAt": "2024-02-22T03:54:35Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "0blicN1H3Cu624BnZPBamA3U584", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmx3b29EeUJkY21v", + "snippet": { + "publishedAt": "2024-02-20T17:23:46Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] - Gemütlich nen bisschen Cargo verfahren in SnowRunner", + "description": "Gemütliches Spielen in SnowRunner \n\nStream vom 28. Februar 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/lwooDyBdcmo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/lwooDyBdcmo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/lwooDyBdcmo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/lwooDyBdcmo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/lwooDyBdcmo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 379, + "resourceId": { + "kind": "youtube#video", + "videoId": "lwooDyBdcmo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "lwooDyBdcmo", + "videoPublishedAt": "2024-02-20T17:23:46Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "wKOul_U0hV9P5_xCFgW3orGw-GI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnBLRUFFNWVkcXV3", + "snippet": { + "publishedAt": "2024-02-19T13:22:26Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - Skill-Issues! An Idiots guide to LethalCompany Modded", + "description": "Chaos in Lethal Company! With @MauVT_ , @Gordy_NatorTV and a bunch of other maniacs! \n\nhttps://twitch.tv/mauvt_\nhttps://twitch.tv/gordy_natortv\n\nStream 16. February 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/pKEAE5edquw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/pKEAE5edquw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/pKEAE5edquw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/pKEAE5edquw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/pKEAE5edquw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 380, + "resourceId": { + "kind": "youtube#video", + "videoId": "pKEAE5edquw" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "pKEAE5edquw", + "videoPublishedAt": "2024-02-19T13:22:26Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "2cV6F8HUi-E7zOr3y2bJF9r-IaE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkJRay1yRk5xMXlV", + "snippet": { + "publishedAt": "2024-02-18T18:50:57Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] - chillen auf dem Community-Server | w/ @scarofman , @mauvt_ , @gordy_natortv", + "description": "Chillig Minecraft auf dem Community-Server mit Scar, Mau und Gordy \n\nhttps://twitch.tv/scarofman\nhttps://twitch.tv/mauvt_\nhttps://twitch.tv/gordy_natortv\n\nStream vom 14. Februar 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/BQk-rFNq1yU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/BQk-rFNq1yU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/BQk-rFNq1yU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/BQk-rFNq1yU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/BQk-rFNq1yU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 381, + "resourceId": { + "kind": "youtube#video", + "videoId": "BQk-rFNq1yU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "BQk-rFNq1yU", + "videoPublishedAt": "2024-02-18T18:50:57Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "gNs6GHLkNaAyDC2aOTy0GEl9lZI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlo3NGEyNEUyYUx3", + "snippet": { + "publishedAt": "2024-02-11T21:41:55Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - idiots on the Road | SnowRunner with @scarofman and @brutesx", + "description": "Casual gameplay of Snowrunner with @brutesx and @scarofman \n\nhttps://twitch.tv/brutesx\nhttps://twitch.tv/scarofman\n\nStream 10. February 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Z74a24E2aLw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Z74a24E2aLw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Z74a24E2aLw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Z74a24E2aLw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Z74a24E2aLw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 382, + "resourceId": { + "kind": "youtube#video", + "videoId": "Z74a24E2aLw" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Z74a24E2aLw", + "videoPublishedAt": "2024-02-11T21:41:55Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "1Q6sDYHewFAm-Dy4vZK9QIiHQb8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk5rdTdnclpnUGJz", + "snippet": { + "publishedAt": "2024-02-11T21:27:25Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - The sea calls! Skull and Bones first look!", + "description": "First look at Skull and Bones \n\nStream 09. Februrary 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/Nku7grZgPbs/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/Nku7grZgPbs/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/Nku7grZgPbs/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/Nku7grZgPbs/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/Nku7grZgPbs/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 383, + "resourceId": { + "kind": "youtube#video", + "videoId": "Nku7grZgPbs" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "Nku7grZgPbs", + "videoPublishedAt": "2024-02-11T21:27:25Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "246u-uOgcxclKkhD27vZZFS-SMk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlRTak5KUkx0Z19j", + "snippet": { + "publishedAt": "2024-02-11T20:57:04Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] - Titanfall 2 Story (Part 2) / 02:05h Snowrunner", + "description": "Erstes Playthrough von Titanfall 2 \n\nGemütliches Snowrunner Spielen mit @scarofman (ab 02:05h) \n\nhttps://twitch.tv/scarofman\n\nStream vom 07. Februar 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/TSjNJRLtg_c/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/TSjNJRLtg_c/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/TSjNJRLtg_c/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/TSjNJRLtg_c/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/TSjNJRLtg_c/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 384, + "resourceId": { + "kind": "youtube#video", + "videoId": "TSjNJRLtg_c" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "TSjNJRLtg_c", + "videoPublishedAt": "2024-02-11T20:57:04Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "hp2GmPLb_sYcue0ZtH9LlotD5SA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkkwanM0THgyNDY4", + "snippet": { + "publishedAt": "2024-02-11T20:56:07Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - Idiots on the road with @scarofman and @brutesx", + "description": "Casual Gameplay of SnowRunner with @scarofman and @brutesx \n\nhttps://twitch.tv/scarofman\nhttps://twitch.tv/brutesx\n\nStream 04. February 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/I0js4Lx2468/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/I0js4Lx2468/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/I0js4Lx2468/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/I0js4Lx2468/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/I0js4Lx2468/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 385, + "resourceId": { + "kind": "youtube#video", + "videoId": "I0js4Lx2468" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "I0js4Lx2468", + "videoPublishedAt": "2024-02-11T20:56:07Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "wexCC_gP-o7ku3Leu0MhBM9ZW8k", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLk1MalJ2UG9rN0xR", + "snippet": { + "publishedAt": "2024-02-09T20:45:22Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] - Titanfall 2 - jetzt wird story gespielt", + "description": "Erster Playthrough von Titanfall 2 (Part 1) \n\nStream vom 01. Februar 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/MLjRvPok7LQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/MLjRvPok7LQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/MLjRvPok7LQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/MLjRvPok7LQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/MLjRvPok7LQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 386, + "resourceId": { + "kind": "youtube#video", + "videoId": "MLjRvPok7LQ" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "MLjRvPok7LQ", + "videoPublishedAt": "2024-02-09T20:45:22Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "q2ZSWShOaBWyeDpZYWiDbFYD8Zk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmJCR3pLWnBXQnhF", + "snippet": { + "publishedAt": "2024-02-09T20:43:52Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - Snowrunner - Idiots on the road", + "description": "Casual Play of Snowrunner with @brutesx and @san_andreas255 \n\nhttps://twitch.tv/brutesx\nhttps://twitch.tv/san_andreas255\n\nStream 31. January 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/bBGzKZpWBxE/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/bBGzKZpWBxE/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/bBGzKZpWBxE/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/bBGzKZpWBxE/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/bBGzKZpWBxE/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 387, + "resourceId": { + "kind": "youtube#video", + "videoId": "bBGzKZpWBxE" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "bBGzKZpWBxE", + "videoPublishedAt": "2024-02-09T20:43:52Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "T57_u-IXtwdFtnlg3MetYHkBjVA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlQ1WlNuS0gtWmhZ", + "snippet": { + "publishedAt": "2024-02-09T20:42:22Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - Rock and Stone! An idiots guide to Deep Rock Galactic - with @makuta87 and @scarofman", + "description": "Casual Gameplay of Deep Rock Galactic with @makuta87, @scarofman and gamespender \n\nhttps://twitch.tv/makuta87\nhttps://twitch.tv/scarofman\n\nStream 27. January 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/T5ZSnKH-ZhY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/T5ZSnKH-ZhY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/T5ZSnKH-ZhY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/T5ZSnKH-ZhY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/T5ZSnKH-ZhY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 388, + "resourceId": { + "kind": "youtube#video", + "videoId": "T5ZSnKH-ZhY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "T5ZSnKH-ZhY", + "videoPublishedAt": "2024-02-09T20:42:22Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "hGAE9zRO7Txj9HkteJHjKvYtW60", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLjk0RVdNNHJOTEVV", + "snippet": { + "publishedAt": "2024-02-09T20:37:52Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - Skill-Issue, the stream! An idiots guide to LethalCompany", + "description": "Modded Gameplay of Lethal Company with @MauVT_ @Gordy_NatorTV @ScarOfMan TwistedKarma @TyiikanRoost SzymonZlayer \n\nhttps://twitch.tv/mauvt_\nhttps://twitch.tv/gordy_natortv\nhttps://twitch.tv/scarofman\nhttps://twitch.tv/tyiikanroost\n\nStream 26. January 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/94EWM4rNLEU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/94EWM4rNLEU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/94EWM4rNLEU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/94EWM4rNLEU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/94EWM4rNLEU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 389, + "resourceId": { + "kind": "youtube#video", + "videoId": "94EWM4rNLEU" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "94EWM4rNLEU", + "videoPublishedAt": "2024-02-09T20:37:52Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "HQqfQrUT2042OOUAF9bmWciZkb4", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkNSMjJCNVFkcFpZ", + "snippet": { + "publishedAt": "2024-02-09T20:37:20Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - SCP all the way - Files Files Files! / 0:47h Stanley Parable Ultimate Edition", + "description": "Finishing the PlayThrough of SCP: Secret Files \n\n0:45h Casual Gameplay of the Stanley Parable Ultimate Edition \n\nStream 24. January 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/CR22B5QdpZY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/CR22B5QdpZY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/CR22B5QdpZY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/CR22B5QdpZY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/CR22B5QdpZY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 390, + "resourceId": { + "kind": "youtube#video", + "videoId": "CR22B5QdpZY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "CR22B5QdpZY", + "videoPublishedAt": "2024-02-09T20:37:20Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "ZJswdXZ1J4xeIu0K6d5Kmff3Cyg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnA5YjQwS0l3R0cw", + "snippet": { + "publishedAt": "2024-02-09T20:13:41Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - SCP all the way - lets try to secure something", + "description": "SCP First Playthrough - Casual Gameplay \n\nStream 24. January 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/p9b40KIwGG0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/p9b40KIwGG0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/p9b40KIwGG0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/p9b40KIwGG0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/p9b40KIwGG0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 391, + "resourceId": { + "kind": "youtube#video", + "videoId": "p9b40KIwGG0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "p9b40KIwGG0", + "videoPublishedAt": "2024-02-09T20:13:41Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "ldLrbJzh7Zv97VdCcgOQ6KbjSFo", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmU2UXRsV0NqcExZ", + "snippet": { + "publishedAt": "2024-02-09T20:13:10Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - An iditos guide to Lethal Company", + "description": "Idiots playing Lethal Company (with Choji, Excent and TwistedKarma) \n\nStream - 19. January 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/e6QtlWCjpLY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/e6QtlWCjpLY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/e6QtlWCjpLY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/e6QtlWCjpLY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/e6QtlWCjpLY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 392, + "resourceId": { + "kind": "youtube#video", + "videoId": "e6QtlWCjpLY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "e6QtlWCjpLY", + "videoPublishedAt": "2024-02-09T20:13:10Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "b4rng7ybQvrqyodh627f71slVN8", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkFyWkNrOEY4bHNJ", + "snippet": { + "publishedAt": "2024-02-09T13:00:15Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] - Müll sammeln und hoffentlich nicht drauf gehen", + "description": "Lethal Company mit Gordy_NatorTV, MauVT_, ScarOfMan und Fox123485 \n\n[https://twitch.tv/MauVT_](https://twitch.tv/MauVT_) \n\n[https://twitch.tv/Gordy_NatorTV](https://twitch.tv/Gordy_NatorTV) \n\n[https://twitch.tv/ScarOfMan](https://twitch.tv/ScarOfMan) \n\nStream vom 17. Januar 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/ArZCk8F8lsI/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/ArZCk8F8lsI/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/ArZCk8F8lsI/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/ArZCk8F8lsI/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/ArZCk8F8lsI/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 393, + "resourceId": { + "kind": "youtube#video", + "videoId": "ArZCk8F8lsI" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "ArZCk8F8lsI", + "videoPublishedAt": "2024-02-09T13:00:15Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "2r5-JERGENhmaTR0hjW56oZ17Lo", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkF2UGpmbEppS3kw", + "snippet": { + "publishedAt": "2024-02-09T07:17:00Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] - Müll sammeln mit @MauVT_ @Gordy_NatorTV und @Scarofman", + "description": "LethalCompany mit MauVT, Gordy_NatorTV und ScarOfMan \n\nhttps://twitch.tv/MauVT_\nhttps://twitch.tv/Gordy_NatorTV\nhttps://twitch.tv/ScarOfMan\n\nStream vom 12. Januar 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/AvPjflJiKy0/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/AvPjflJiKy0/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/AvPjflJiKy0/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/AvPjflJiKy0/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/AvPjflJiKy0/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 394, + "resourceId": { + "kind": "youtube#video", + "videoId": "AvPjflJiKy0" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "AvPjflJiKy0", + "videoPublishedAt": "2024-02-09T07:17:00Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "_2C7PCPTNqnk3P3Hny28FhQKvfs", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkpkV194MkF4SnFr", + "snippet": { + "publishedAt": "2024-02-09T07:05:08Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] - Nen bissle Minecraft daddeln auf dem Community-Server", + "description": "Gemütliches Minecraft Spielen auf dem Community-Server - Stream vom 10. Januar 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/JdW_x2AxJqk/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/JdW_x2AxJqk/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/JdW_x2AxJqk/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/JdW_x2AxJqk/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/JdW_x2AxJqk/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 395, + "resourceId": { + "kind": "youtube#video", + "videoId": "JdW_x2AxJqk" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "JdW_x2AxJqk", + "videoPublishedAt": "2024-02-09T07:05:08Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "vPiCtNjBP30dKb5wQT6o9VKUHkA", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnRidGFSbGdqa3J3", + "snippet": { + "publishedAt": "2024-02-09T00:32:37Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] - OnceHuman - walking around and trying to get stuff done", + "description": "Casual Play of OnceHuman - Stream 05. January 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/tbtaRlgjkrw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/tbtaRlgjkrw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/tbtaRlgjkrw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/tbtaRlgjkrw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/tbtaRlgjkrw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 396, + "resourceId": { + "kind": "youtube#video", + "videoId": "tbtaRlgjkrw" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "tbtaRlgjkrw", + "videoPublishedAt": "2024-02-09T00:32:37Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "uunncYm4kMzxW22NC-oygGwpghI", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmpsdnBQcm1uUVZN", + "snippet": { + "publishedAt": "2024-02-09T00:31:13Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] - Minecraft daddeln auf dem Community-Server", + "description": "Gemütliches daddeln auf dem Community Minecraft Server - Stream vom 03. Januar 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/jlvpPrmnQVM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/jlvpPrmnQVM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/jlvpPrmnQVM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/jlvpPrmnQVM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/jlvpPrmnQVM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 397, + "resourceId": { + "kind": "youtube#video", + "videoId": "jlvpPrmnQVM" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "jlvpPrmnQVM", + "videoPublishedAt": "2024-02-09T00:31:13Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "zIxjycwUM16TJRr_ZZrXa_UJ700", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLmlzSHRLTlB1dFpZ", + "snippet": { + "publishedAt": "2024-02-09T00:28:42Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] OnceHuman - Beta - first looks at the game", + "description": "Once Human - First look at the closed Beta - Stream 02. January 2024 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/isHtKNPutZY/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/isHtKNPutZY/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/isHtKNPutZY/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/isHtKNPutZY/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/isHtKNPutZY/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 398, + "resourceId": { + "kind": "youtube#video", + "videoId": "isHtKNPutZY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "isHtKNPutZY", + "videoPublishedAt": "2024-02-09T00:28:42Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "V74kt6_OIOXQ0V6IeZZjBXMV2YE", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkp3UnVNX3pIbXFv", + "snippet": { + "publishedAt": "2024-02-08T21:26:25Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] nen bisschen abhängen auf dem Minecraft Community Server", + "description": "Gemütliches Minecraft Spielen auf dem Community-Server - Stream vom 27. Dezember 2023 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/JwRuM_zHmqo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/JwRuM_zHmqo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/JwRuM_zHmqo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/JwRuM_zHmqo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/JwRuM_zHmqo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 399, + "resourceId": { + "kind": "youtube#video", + "videoId": "JwRuM_zHmqo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "JwRuM_zHmqo", + "videoPublishedAt": "2024-02-08T21:26:25Z" + }, + "status": { + "privacyStatus": "public" + } + } + ], + "pageInfo": { + "totalResults": 405, + "resultsPerPage": 50 + } + }, + { + "kind": "youtube#playlistItemListResponse", + "etag": "2-P3NxFjDY4Mai7z5nanbRDgo50", + "prevPageToken": "EAEaB1BUOkNKQUQ", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "tnpNae_fBGmPb3Lo-JtCKMpekkk", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkp3UnVNX3pIbXFv", + "snippet": { + "publishedAt": "2024-02-08T21:26:25Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] nen bisschen abhängen auf dem Minecraft Community Server", + "description": "Gemütliches Minecraft Spielen auf dem Community-Server - Stream vom 27. Dezember 2023 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/JwRuM_zHmqo/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/JwRuM_zHmqo/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/JwRuM_zHmqo/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/JwRuM_zHmqo/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/JwRuM_zHmqo/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 400, + "resourceId": { + "kind": "youtube#video", + "videoId": "JwRuM_zHmqo" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "JwRuM_zHmqo", + "videoPublishedAt": "2024-02-08T21:26:25Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "0LieP6F-hUSm1WIll-T1ADN_gGM", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLkdPWkQ5ZllPQzVF", + "snippet": { + "publishedAt": "2024-02-08T20:51:47Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[DEU] Nen bisschen Minecraft auf dem Community-Server", + "description": "Gemütliches Spielen auf dem Community-Server. Stream vom 20. Dezember 2023 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/GOZD9fYOC5E/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/GOZD9fYOC5E/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/GOZD9fYOC5E/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/GOZD9fYOC5E/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/GOZD9fYOC5E/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 401, + "resourceId": { + "kind": "youtube#video", + "videoId": "GOZD9fYOC5E" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "GOZD9fYOC5E", + "videoPublishedAt": "2024-02-08T20:51:47Z" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "e0zdlmHpNngdhySTdygYeHu_62A", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlNuS0V2TndKT0ZZ", + "snippet": { + "publishedAt": "2024-02-08T18:56:07Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] On we go in The Evil Within 2! Dont loose your sanity!", + "description": "Casual Playthrough of The Evil Within 2 - Stream 23. December 2023 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/SnKEvNwJOFY/default.jpg?sqp=CJSIwdAG&rs=AOn4CLAW0e7buTJTQlY1ENRrnj_DoaP96g", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/SnKEvNwJOFY/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLD_Vs965aNiMhSSGyS_1DHMt_42RA", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/SnKEvNwJOFY/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCrYZQBFyTpGcGgzD-_EpDML7RNAQ", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/SnKEvNwJOFY/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLBhoScLawCDSPPhcPLqV4Hbw_RqgA", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/SnKEvNwJOFY/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLB93Ar4q0ArlgS9qGvm9nG85ulcJw", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 402, + "resourceId": { + "kind": "youtube#video", + "videoId": "SnKEvNwJOFY" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "SnKEvNwJOFY", + "videoPublishedAt": "2024-02-08T18:56:07Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "F5IMhwxT4udps6PNqFimePpCKus", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLnhYTGxES192bEJj", + "snippet": { + "publishedAt": "2024-02-08T18:52:29Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "[ENG] The Evil Within 2 - GOOOOP! The search for Lily goes on", + "description": "Casual Playthrough of The Evil Within 2 - Stream 16. December 2023 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/xXLlDK_vlBc/default.jpg?sqp=CJSIwdAG&rs=AOn4CLDS_r0hCVFq7l39O4ZWjrqdf8zMDA", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/xXLlDK_vlBc/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLD-scmt85xDMKAJEUGyjQZQ26BiXg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/xXLlDK_vlBc/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDb3bmnNGxnLsRL5tl0XtfZIcufuA", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/xXLlDK_vlBc/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCrPLZ7acaG7xByUK5IqlE_-T5xnw", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/xXLlDK_vlBc/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCWCGzTQHmUVxa3tAzpv_JrxNi1Jw", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 403, + "resourceId": { + "kind": "youtube#video", + "videoId": "xXLlDK_vlBc" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "xXLlDK_vlBc", + "videoPublishedAt": "2024-02-08T18:52:29Z" + }, + "status": { + "privacyStatus": "private" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "MjxYnmnM4VflOmYN3i0tMAeLjJg", + "id": "VVVLNk5pdk5uWHBBUXIxdXVCX1JybHpBLlRKaVkwTGRPd184", + "snippet": { + "publishedAt": "2024-02-08T18:43:48Z", + "channelId": "UCK6NivNnXpAQr1uuB_RrlzA", + "title": "The Evil Within 2 - Long live the GOOOP and may we find a lot of it", + "description": "Casual Playthrough of The Evil Within 2 - Stream 15. December 2023 -- Watch live at https://www.twitch.tv/devilszone_", + "thumbnails": { + "default": { + "url": "https://i9.ytimg.com/vi/TJiY0LdOw_8/default.jpg?sqp=CJSIwdAG&rs=AOn4CLC_EQwWOiBZHSPGHfosqAu-Fh95sg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i9.ytimg.com/vi/TJiY0LdOw_8/mqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDYeYh2tu85gUmOHGwYMAvyBnWquQ", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i9.ytimg.com/vi/TJiY0LdOw_8/hqdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLCJ-QaCHJFXLOaNlE-1qCvig-cfDg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i9.ytimg.com/vi/TJiY0LdOw_8/sddefault.jpg?sqp=CJSIwdAG&rs=AOn4CLDA8gPYgbFGo8fHJl8Pc24D3pE-0Q", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i9.ytimg.com/vi/TJiY0LdOw_8/maxresdefault.jpg?sqp=CJSIwdAG&rs=AOn4CLAP5yAvoynpR72rB3ZkyIeEqnvMMg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "DevilsZone Vault", + "playlistId": "UUK6NivNnXpAQr1uuB_RrlzA", + "position": 404, + "resourceId": { + "kind": "youtube#video", + "videoId": "TJiY0LdOw_8" + }, + "videoOwnerChannelTitle": "DevilsZone Vault", + "videoOwnerChannelId": "UCK6NivNnXpAQr1uuB_RrlzA" + }, + "contentDetails": { + "videoId": "TJiY0LdOw_8", + "videoPublishedAt": "2024-02-08T18:43:48Z" + }, + "status": { + "privacyStatus": "private" + } + } + ], + "pageInfo": { + "totalResults": 405, + "resultsPerPage": 50 + } + } +] \ No newline at end of file diff --git a/backend/src/.vs/ProjectSettings.json b/backend/src/.vs/ProjectSettings.json new file mode 100644 index 0000000..f8b4888 --- /dev/null +++ b/backend/src/.vs/ProjectSettings.json @@ -0,0 +1,3 @@ +{ + "CurrentProjectSetting": null +} \ No newline at end of file diff --git a/backend/src/.vs/VSWorkspaceState.json b/backend/src/.vs/VSWorkspaceState.json new file mode 100644 index 0000000..e249072 --- /dev/null +++ b/backend/src/.vs/VSWorkspaceState.json @@ -0,0 +1,7 @@ +{ + "ExpandedNodes": [ + "" + ], + "SelectedNode": "\\app.module.ts", + "PreviewInSolutionExplorer": false +} \ No newline at end of file diff --git a/backend/src/.vs/slnx.sqlite b/backend/src/.vs/slnx.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..bd4f48b99b9ca8e9151a62d86c8db5b5822b6dd1 GIT binary patch literal 176128 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%0Aoe` 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 V|2y*ZLH~WPzY(ARs?Api{ePak1cLwo literal 0 HcmV?d00001 diff --git a/backend/src/.vs/src/v17/.wsuo b/backend/src/.vs/src/v17/.wsuo new file mode 100644 index 0000000000000000000000000000000000000000..140d878c6110965b00c82ee7846475e7432b37f8 GIT binary patch literal 16384 zcmeHOTZ~&r86IybZ7H`VZJ?oqP&TEPJ?`<{UcxrswcWJc?QY}EZbJ>rv3T5wfpd#_mBDE4oRUZ&VqK{D<^#Mglso((#RTL!D7ZALnN=f*>b3D7pXYIpT z8({UI6QA)of+6aG6QYh^Qydm)QNz6;AerK1F{eA0`3Fu2i^_52gsrQ5|H)Wf&0Ps^C8@2y19=HH}26zIP1ug|o`}Gt) zb8ZtBLDvA?S@^mJJmIW(-t@tnm=W}h4J%vEEB)VF@RhpBEB$AYmo|;C1PY60A5=sh z_j70g{r@v)PvJj$|I}S$rfsB-^h$pjdFitglJNO8`2QPd2YVn3s&zynIy7AhS}6_9 z7DBF|NZ=ZSZi`z5+t(=gr>-S!LdVA*FH1;gpAqJuy-q?CXF;`M_IMdjHKb(Fo(kIG zJ^~N)5r2ILbYC*5Q;+tJf0m%#qu`l3z85^$2Yn5d{i#=leWY7GZ(aK+K5&si=r3cG zYp^_ZaTcOA0h&^XV|~!4t!LW_)OOVTtZfdQk-`b2QTLI@XTU4< zVHsCrOG{g2d5sT9-);OCOxdh~auM2yTyM*rYytG2xfUVU6J)yEow`?!zZ?A}lk=u& zqw-t4vqTX+^SmjA=zkx5TaG=!jjsGg-AkDzxY1t*4UWRRX%DP^uu5%TgMIY>7>d&u zC!|0jk1<}wkK;_OPkli7Z<%u8z5+)5&-Ea#19|12vi}+b3P5lA8yIa`3sq3%I;eeYdRf5<&}U3Tp1jhhuf$k^;G}QSw6rc)B9j>Vb#rEN zeUQ4ib48H$pL8WywYpz##b@b%a2`+}_X~rT=eUtR@$nzM>NO zS>7tJe#}$BcBVnwzZ+V;@^U^pVURRuU0&X~p8Ga%Moc13wd~<4WQ{u4?hEE1i}Z|X zFAZP`GIOGwRadEf?~=TP2C z4IrZT19=T0vS&>(rRZ5W5O7sFNLp>U2Fe^Aa8sMW0Z=R!Zc}OLTyfjSTg=-LuM9Iyt z=q#RPsG!v)aItOo6HuiEB+(DMl8jQOg=qILA)moomD`=nq}2 zbVL^`Sg+ujowLZ7N8JUK;)+QT?^26)X|yegvG{X>D{}n-`5y$hZ(yB8vPYuaMP$v# zRfIhDpR)Dl{$wx1=bn%i<9A+7KJvqNkH7WDH?Qpf`7#2*tcaKHzI5%s_H&T8w;`g$%^FSM$9t%*;K`e}Wwspn4BTg_HpU&%IG#d3WmQ>mZL<+Z|^ zUMuE|Mj=<#$~Aw}*xWp?W`f~JcsigYBax^QR)Z-e8AEFVnN&KKNhDI~w0dEq?^YZ2 zN~>9}*BW|7YnIRGle?r0DKPifI0EHkqwd>f_l=2ZCA(;eTqIVEL_>KDa#)Kf;aIVx z#3OlKDTM=xgjS5^qmjUc)ToPh*~PcD^ZOS67c+EkYM;>bU`&hVRYeblAsfMXTuDSB zAQ4?Fg%V+14@DDxRSg9Lp-rbU#1^Q2WtY+wR{+`~Rc; z|NqDS|M%OXI&b=R-OoRd_W!-jXSw&oySEbCxt#k?qy4{|Yya=rD~v$;hjE-o=sZDY z2<`_l=m@|F^6Cb(oNwO1(Id~rF*^5~@u?M+b>rvNR}Q@L%NH-+-|d@Q8ucLe-dOVs zxE^7QgD|Fqpy$XSkGYwck#YJG&Kn`usnaHPN;)Se=+RxFZ=Oky^rj_`|5G04f*fef1ZPu z_J0G5TKVY7H>HG8~a16#`A)NZuBh+SVSv| Y=KrDH+QajI{IAY_pjZB5$Sdjp8$;8)g#Z8m literal 0 HcmV?d00001 diff --git a/backend/src/.vs/src/v17/DocumentLayout.json b/backend/src/.vs/src/v17/DocumentLayout.json new file mode 100644 index 0000000..0fe169d --- /dev/null +++ b/backend/src/.vs/src/v17/DocumentLayout.json @@ -0,0 +1,41 @@ +{ + "Version": 1, + "WorkspaceRootPath": "C:\\Users\\PSaggau.PSLaptop\\Documents\\Projekte\\Youtube_Studio_Flow\\backend\\src\\", + "Documents": [ + { + "AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\PSaggau.PSLaptop\\Documents\\Projekte\\Youtube_Studio_Flow\\backend\\src\\main.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}", + "RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:main.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}" + } + ], + "DocumentGroupContainers": [ + { + "Orientation": 0, + "VerticalTabListWidth": 256, + "DocumentGroups": [ + { + "DockedWidth": 200, + "SelectedChildIndex": 1, + "Children": [ + { + "$type": "Bookmark", + "Name": "ST:0:0:{57d563b6-44a5-47df-85be-f4199ad6b651}" + }, + { + "$type": "Document", + "DocumentIndex": 0, + "Title": "main.ts", + "DocumentMoniker": "C:\\Users\\PSaggau.PSLaptop\\Documents\\Projekte\\Youtube_Studio_Flow\\backend\\src\\main.ts", + "RelativeDocumentMoniker": "main.ts", + "ToolTip": "C:\\Users\\PSaggau.PSLaptop\\Documents\\Projekte\\Youtube_Studio_Flow\\backend\\src\\main.ts", + "RelativeToolTip": "main.ts", + "ViewState": "AgIAAAAAAAAAAAAAAAAAAAMAAAAuAAAAAAAAAA==", + "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|", + "WhenOpened": "2026-04-28T14:19:36.103Z", + "EditorCaption": "" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts new file mode 100644 index 0000000..506e3c4 --- /dev/null +++ b/backend/src/app.module.ts @@ -0,0 +1,64 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { BullModule } from '@nestjs/bullmq'; +import { PrismaModule } from './shared/prisma/prisma.module'; +import { RenderEngineModule } from './shared/render-engine/render-engine.module'; +import { QuotaModule } from './shared/quota/quota.module'; +import { AuditModule } from './shared/audit/audit.module'; +import { AuthModule } from './modules/auth/auth.module'; +import { VideosModule } from './modules/videos/videos.module'; +import { BlocksModule } from './modules/blocks/blocks.module'; +import { TemplatesModule } from './modules/templates/templates.module'; +import { VideoConfigsModule } from './modules/video-configs/video-configs.module'; +import { CollaboratorsModule } from './modules/collaborators/collaborators.module'; +import { BulkJobsModule } from './modules/bulk-jobs/bulk-jobs.module'; +import { SavedViewsModule } from './modules/saved-views/saved-views.module'; +import { LintingModule } from './modules/linting/linting.module'; +import { CalendarModule } from './modules/calendar/calendar.module'; +import { ImportsModule } from './modules/imports/imports.module'; +import { ExportsModule } from './modules/exports/exports.module'; +import { YouTubeSyncModule } from './modules/youtube-sync/youtube-sync.module'; +import { TeamsModule } from './modules/teams/teams.module'; +import { TeamVariablesModule } from './modules/team-variables/team-variables.module'; +import { PlaylistsModule } from './modules/playlists/playlists.module'; +import { SystemVariablesModule } from './shared/system-variables/system-variables.module'; +import { CampaignsModule } from './modules/campaigns/campaigns.module'; +import { QuotaController } from './modules/quota/quota.controller'; +import { AuditLogsController } from './modules/audit-logs/audit-logs.controller'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + BullModule.forRootAsync({ + useFactory: (config: ConfigService) => ({ + connection: { url: config.get('REDIS_URL') ?? 'redis://localhost:6379' }, + }), + inject: [ConfigService], + }), + PrismaModule, + RenderEngineModule, + QuotaModule, + AuditModule, + AuthModule, + VideosModule, + BlocksModule, + TemplatesModule, + VideoConfigsModule, + CollaboratorsModule, + BulkJobsModule, + SavedViewsModule, + LintingModule, + CalendarModule, + ImportsModule, + ExportsModule, + YouTubeSyncModule, + TeamsModule, + TeamVariablesModule, + PlaylistsModule, + SystemVariablesModule, + CampaignsModule, + ], + controllers: [HealthController, QuotaController, AuditLogsController], +}) +export class AppModule {} diff --git a/backend/src/health.controller.ts b/backend/src/health.controller.ts new file mode 100644 index 0000000..e06c46a --- /dev/null +++ b/backend/src/health.controller.ts @@ -0,0 +1,11 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; + +@ApiTags('health') +@Controller('health') +export class HealthController { + @Get() + health() { + return { status: 'ok' }; + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 0000000..d44989c --- /dev/null +++ b/backend/src/main.ts @@ -0,0 +1,42 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import * as cookieParser from 'cookie-parser'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.use(cookieParser()); + + app.enableCors({ + origin: process.env.FRONTEND_URL ?? 'http://localhost:3000', + credentials: true, + }); + + app.setGlobalPrefix('api/v1'); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + + const swaggerConfig = new DocumentBuilder() + .setTitle('StudioFlow API') + .setDescription('YouTube Upload Manager — REST API') + .setVersion('1.0') + .addBearerAuth() + .build(); + + const document = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('api/docs', app, document); + + const port = process.env.PORT ?? 3001; + await app.listen(port); + console.log(`StudioFlow API running on :${port}`); +} + +bootstrap(); diff --git a/backend/src/modules/audit-logs/audit-logs.controller.ts b/backend/src/modules/audit-logs/audit-logs.controller.ts new file mode 100644 index 0000000..87af819 --- /dev/null +++ b/backend/src/modules/audit-logs/audit-logs.controller.ts @@ -0,0 +1,79 @@ +import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { PrismaService } from '../../shared/prisma/prisma.service'; + +@ApiTags('audit-logs') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('audit-logs') +export class AuditLogsController { + constructor(private readonly prisma: PrismaService) {} + + @Get() + @ApiOperation({ summary: 'Team audit trail (paginated, filtered)' }) + async findAll( + @Req() req: any, + @Query('page') page = '1', + @Query('limit') limit = '50', + @Query('entityType') entityType?: string, + @Query('action') action?: string, + ) { + const members = await this.prisma.teamMember.findMany({ + where: { teamId: req.user.teamId }, + select: { userId: true }, + }); + const actorIds = members.map((m) => m.userId); + + const where = { + actorId: { in: actorIds }, + ...(entityType ? { entityType } : {}), + ...(action ? { action } : {}), + }; + + const [logs, total] = await Promise.all([ + this.prisma.auditLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (Number(page) - 1) * Number(limit), + take: Number(limit), + }), + this.prisma.auditLog.count({ where }), + ]); + + const uniqueActorIds = [...new Set(logs.map((l) => l.actorId))]; + const users = await this.prisma.user.findMany({ + where: { id: { in: uniqueActorIds } }, + select: { id: true, name: true, email: true }, + }); + const userMap = Object.fromEntries(users.map((u) => [u.id, u])); + + return { + data: logs.map((log) => ({ ...log, actor: userMap[log.actorId] ?? null })), + total, + page: Number(page), + limit: Number(limit), + }; + } + + @Get(':entityType/:entityId') + @ApiOperation({ summary: 'Audit history for a specific entity' }) + async findForEntity( + @Param('entityType') entityType: string, + @Param('entityId') entityId: string, + ) { + const logs = await this.prisma.auditLog.findMany({ + where: { entityType, entityId }, + orderBy: { createdAt: 'desc' }, + }); + + const uniqueActorIds = [...new Set(logs.map((l) => l.actorId))]; + const users = await this.prisma.user.findMany({ + where: { id: { in: uniqueActorIds } }, + select: { id: true, name: true, email: true }, + }); + const userMap = Object.fromEntries(users.map((u) => [u.id, u])); + + return logs.map((log) => ({ ...log, actor: userMap[log.actorId] ?? null })); + } +} diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..c407443 --- /dev/null +++ b/backend/src/modules/auth/auth.controller.ts @@ -0,0 +1,91 @@ +import { Controller, Get, Patch, Post, Req, Res, UseGuards, Body } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Response, Request } from 'express'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; + +@ApiTags('auth') +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Get('google') + @ApiOperation({ summary: 'Redirect to Google OAuth' }) + @UseGuards(AuthGuard('google')) + googleAuth() {} + + @Get('google/callback') + @ApiOperation({ summary: 'Google OAuth callback' }) + @UseGuards(AuthGuard('google')) + async googleCallback(@Req() req: Request, @Res() res: Response) { + const { user, teamId, teamRole } = req.user as any; + const accessToken = this.authService.issueJwt(user, teamId, teamRole); + const refreshToken = this.authService.issueRefreshToken(user); + + res.cookie('refresh_token', refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + const frontendUrl = process.env.FRONTEND_URL ?? 'http://localhost:3000'; + res.redirect(`${frontendUrl}/auth/callback?token=${accessToken}`); + } + + @Get('me') + @ApiOperation({ summary: 'Get current user with team context' }) + @UseGuards(JwtAuthGuard) + me(@Req() req: Request) { + const user = req.user as any; + return { + id: user.id, + email: user.email, + name: user.name, + isAppAdmin: user.isAppAdmin, + teamId: user.teamId, + teamRole: user.teamRole, + }; + } + + @Post('refresh') + @ApiOperation({ summary: 'Refresh access token' }) + async refresh(@Req() req: Request) { + const token = req.cookies?.['refresh_token']; + return this.authService.refreshAccessToken(token); + } + + @Get('me/preferences') + @ApiOperation({ summary: 'Get current user preferences' }) + @UseGuards(JwtAuthGuard) + getPreferences(@Req() req: Request) { + return this.authService.getPreferences((req.user as any).id); + } + + @Patch('me/preferences') + @ApiOperation({ summary: 'Patch current user preferences (shallow merge)' }) + @UseGuards(JwtAuthGuard) + updatePreferences(@Req() req: Request, @Body() body: Record) { + return this.authService.updatePreferences((req.user as any).id, body); + } + + @Post('switch-team') + @ApiOperation({ summary: 'Switch active team — returns a new access token' }) + @UseGuards(JwtAuthGuard) + async switchTeam(@Req() req: Request, @Body('teamId') teamId: string) { + const user = req.user as any; + return this.authService.switchTeam(user.id, teamId); + } + + @Post('logout') + @ApiOperation({ summary: 'Clear refresh token cookie' }) + logout(@Res() res: Response) { + (res as any).clearCookie('refresh_token', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + }); + (res as any).json({ message: 'Logged out' }); + } +} diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..edb5388 --- /dev/null +++ b/backend/src/modules/auth/auth.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { GoogleStrategy } from './strategies/google.strategy'; +import { JwtStrategy } from './strategies/jwt.strategy'; + +@Module({ + imports: [ + PassportModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + secret: config.getOrThrow('JWT_SECRET'), + signOptions: { expiresIn: '15m' }, + }), + inject: [ConfigService], + }), + ], + providers: [AuthService, GoogleStrategy, JwtStrategy], + controllers: [AuthController], + exports: [AuthService, JwtModule], +}) +export class AuthModule {} diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..21a0c7b --- /dev/null +++ b/backend/src/modules/auth/auth.service.ts @@ -0,0 +1,218 @@ +import { Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'; +import { google } from 'googleapis'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { User, TeamRole } from '@prisma/client'; + +export interface AuthResult { + user: User; + teamId: string; + teamRole: TeamRole; +} + +@Injectable() +export class AuthService { + private readonly encKey: Buffer; + + constructor( + private readonly prisma: PrismaService, + private readonly jwt: JwtService, + private readonly config: ConfigService, + ) { + const raw = this.config.get('TOKEN_ENCRYPTION_KEY') ?? 'fallback_dev_key_32chars_padded!!'; + this.encKey = scryptSync(raw, 'studioflow-salt', 32); + } + + async upsertGoogleUser( + profile: any, + accessToken: string, + refreshToken: string, + ): Promise { + const email: string = profile.emails?.[0]?.value; + const googleId: string = profile.id; + const name: string = profile.displayName; + + const user = await this.prisma.user.upsert({ + where: { googleId }, + create: { email, name, googleId }, + update: { email, name }, + }); + + const memberships = await this.prisma.teamMember.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: 'asc' }, + }); + + let teamId: string; + let teamRole: TeamRole; + + if (memberships.length === 0) { + const { youtubeChannelId, channelName, uploadsPlaylistId } = + await this.fetchYouTubeChannelInfo(accessToken, refreshToken); + + const slug = this.slugify(name || email.split('@')[0]); + const uniqueSlug = await this.ensureUniqueSlug(slug); + + const team = await this.prisma.team.create({ + data: { + name: name || email.split('@')[0], + slug: uniqueSlug, + members: { + create: { userId: user.id, role: TeamRole.OWNER }, + }, + channels: { + create: { + youtubeChannelId, + name: channelName, + uploadsPlaylistId, + youtubeAccessToken: this.encrypt(accessToken), + youtubeRefreshToken: refreshToken ? this.encrypt(refreshToken) : undefined, + youtubeTokenExpiry: new Date(Date.now() + 3600 * 1000), + connectedBy: user.id, + }, + }, + }, + }); + + teamId = team.id; + teamRole = TeamRole.OWNER; + } else { + const primary = memberships[0]; + teamId = primary.teamId; + teamRole = primary.role; + + // Refresh tokens on channels this user connected. + // Only overwrite youtubeRefreshToken when Google actually returns one + // (it only does on first auth or when prompt=consent forces re-consent). + await this.prisma.channel.updateMany({ + where: { teamId, connectedBy: user.id }, + data: { + youtubeAccessToken: this.encrypt(accessToken), + ...(refreshToken && { youtubeRefreshToken: this.encrypt(refreshToken) }), + youtubeTokenExpiry: new Date(Date.now() + 3600 * 1000), + }, + }); + } + + return { user, teamId, teamRole }; + } + + issueJwt(user: User, teamId: string, teamRole: TeamRole): string { + return this.jwt.sign( + { sub: user.id, email: user.email, teamId, teamRole }, + { expiresIn: '15m' }, + ); + } + + issueRefreshToken(user: User): string { + return this.jwt.sign( + { sub: user.id }, + { + secret: this.config.get('JWT_REFRESH_SECRET') ?? this.config.get('JWT_SECRET'), + expiresIn: '7d', + }, + ); + } + + async refreshAccessToken(refreshToken: string | undefined): Promise<{ accessToken: string }> { + if (!refreshToken) throw new Error('No refresh token'); + const payload = this.jwt.verify<{ sub: string }>(refreshToken, { + secret: this.config.get('JWT_REFRESH_SECRET') ?? this.config.get('JWT_SECRET'), + }); + const user = await this.prisma.user.findUniqueOrThrow({ where: { id: payload.sub } }); + const membership = await this.prisma.teamMember.findFirst({ + where: { userId: user.id }, + orderBy: { createdAt: 'asc' }, + }); + if (!membership) throw new Error('User has no team'); + return { accessToken: this.issueJwt(user, membership.teamId, membership.role) }; + } + + async getPreferences(userId: string): Promise> { + const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } }); + return (user.preferences as Record) ?? {}; + } + + async updatePreferences(userId: string, patch: Record): Promise> { + const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } }); + const merged = { ...((user.preferences as Record) ?? {}), ...patch }; + await this.prisma.user.update({ where: { id: userId }, data: { preferences: merged as any } }); + return merged; + } + + async switchTeam(userId: string, teamId: string): Promise<{ accessToken: string }> { + const membership = await this.prisma.teamMember.findUnique({ + where: { userId_teamId: { userId, teamId } }, + }); + if (!membership) throw new Error('Not a member of this team'); + const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } }); + return { accessToken: this.issueJwt(user, membership.teamId, membership.role) }; + } + + decryptToken(encrypted: string): string { + return this.decrypt(encrypted); + } + + encryptToken(plain: string): string { + return this.encrypt(plain); + } + + private async fetchYouTubeChannelInfo(accessToken: string, refreshToken: string) { + const oauth2 = new google.auth.OAuth2( + this.config.get('GOOGLE_CLIENT_ID'), + this.config.get('GOOGLE_CLIENT_SECRET'), + ); + oauth2.setCredentials({ access_token: accessToken, refresh_token: refreshToken }); + const yt = google.youtube({ version: 'v3', auth: oauth2 }); + + try { + const res = await yt.channels.list({ part: ['snippet', 'contentDetails'], mine: true }); + const ch = res.data.items?.[0]; + return { + youtubeChannelId: ch?.id ?? `unknown-${Date.now()}`, + channelName: ch?.snippet?.title ?? 'My Channel', + uploadsPlaylistId: ch?.contentDetails?.relatedPlaylists?.uploads ?? undefined, + }; + } catch { + return { + youtubeChannelId: `unknown-${Date.now()}`, + channelName: 'My Channel', + uploadsPlaylistId: undefined, + }; + } + } + + private slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 50) || 'team'; + } + + private async ensureUniqueSlug(base: string): Promise { + let slug = base; + let i = 2; + while (await this.prisma.team.findUnique({ where: { slug } })) { + slug = `${base}-${i++}`; + } + return slug; + } + + private encrypt(text: string): string { + const iv = randomBytes(16); + const cipher = createCipheriv('aes-256-cbc', this.encKey, iv); + const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]); + return iv.toString('hex') + ':' + encrypted.toString('hex'); + } + + private decrypt(text: string): string { + const [ivHex, encHex] = text.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + const encrypted = Buffer.from(encHex, 'hex'); + const decipher = createDecipheriv('aes-256-cbc', this.encKey, iv); + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8'); + } +} diff --git a/backend/src/modules/auth/decorators/roles.decorator.ts b/backend/src/modules/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..dacfd44 --- /dev/null +++ b/backend/src/modules/auth/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { TeamRole } from '@prisma/client'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: TeamRole[]) => SetMetadata(ROLES_KEY, roles); diff --git a/backend/src/modules/auth/guards/jwt-auth.guard.ts b/backend/src/modules/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..2155290 --- /dev/null +++ b/backend/src/modules/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/backend/src/modules/auth/guards/roles.guard.ts b/backend/src/modules/auth/guards/roles.guard.ts new file mode 100644 index 0000000..f186fe4 --- /dev/null +++ b/backend/src/modules/auth/guards/roles.guard.ts @@ -0,0 +1,35 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { TeamRole } from '@prisma/client'; +import { ROLES_KEY } from '../decorators/roles.decorator'; + +const ROLE_PRIORITY: Record = { + [TeamRole.OWNER]: 5, + [TeamRole.ADMIN]: 4, + [TeamRole.EDITOR]: 3, + [TeamRole.REVIEWER]: 2, + [TeamRole.READONLY]: 1, +}; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const required = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!required || required.length === 0) return true; + + const { user } = context.switchToHttp().getRequest(); + if (!user) throw new ForbiddenException(); + + const userPriority = ROLE_PRIORITY[user.teamRole as TeamRole] ?? 0; + const minRequired = Math.min(...required.map((r) => ROLE_PRIORITY[r])); + + if (userPriority < minRequired) throw new ForbiddenException('Insufficient role'); + return true; + } +} diff --git a/backend/src/modules/auth/strategies/google.strategy.ts b/backend/src/modules/auth/strategies/google.strategy.ts new file mode 100644 index 0000000..998e6a1 --- /dev/null +++ b/backend/src/modules/auth/strategies/google.strategy.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, VerifyCallback } from 'passport-google-oauth20'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { AuthService } from '../auth.service'; +import { PrismaService } from '../../../shared/prisma/prisma.service'; + +@Injectable() +export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { + constructor( + private readonly config: ConfigService, + private readonly authService: AuthService, + private readonly prisma: PrismaService, + private readonly jwt: JwtService, + ) { + super({ + clientID: config.getOrThrow('GOOGLE_CLIENT_ID'), + clientSecret: config.getOrThrow('GOOGLE_CLIENT_SECRET'), + callbackURL: config.get('GOOGLE_CALLBACK_URL') ?? 'http://localhost:3001/api/v1/auth/google/callback', + scope: ['email', 'profile', 'https://www.googleapis.com/auth/youtube'], + }); + } + + override authorizationParams(options: any): Record { + const params: Record = { access_type: 'offline' }; + if (options?.prompt) params.prompt = options.prompt; + return params; + } + + override authenticate(req: any, options?: any) { + this.resolvePrompt(req) + .then((prompt) => super.authenticate(req, { ...options, ...(prompt ? { prompt } : {}) })) + .catch(() => super.authenticate(req, options)); + } + + private async resolvePrompt(req: any): Promise { + // Extract bearer token from Authorization header (sent by the frontend before redirect) + const authHeader: string | undefined = req.headers?.authorization; + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; + if (!token) return null; // new user — access_type=offline alone gives a refresh token on first auth + + try { + const payload = this.jwt.verify<{ sub: string; teamId: string }>(token, { + secret: this.config.getOrThrow('JWT_SECRET'), + }); + const channel = await this.prisma.channel.findFirst({ + where: { teamId: payload.teamId, connectedBy: payload.sub }, + select: { youtubeRefreshToken: true }, + }); + // Only force consent when the refresh token is genuinely missing + return channel && !channel.youtubeRefreshToken ? 'consent' : null; + } catch { + return null; + } + } + + async validate( + accessToken: string, + refreshToken: string, + profile: any, + done: VerifyCallback, + ) { + // authResult contains { user, teamId, teamRole } — passed as req.user to the controller + const authResult = await this.authService.upsertGoogleUser(profile, accessToken, refreshToken); + done(null, authResult as any); + } +} diff --git a/backend/src/modules/auth/strategies/jwt.strategy.ts b/backend/src/modules/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..25090c7 --- /dev/null +++ b/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -0,0 +1,39 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../../../shared/prisma/prisma.service'; +import { TeamRole } from '@prisma/client'; + +interface JwtPayload { + sub: string; + email: string; + teamId: string; + teamRole: TeamRole; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor( + config: ConfigService, + private readonly prisma: PrismaService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: config.getOrThrow('JWT_SECRET'), + }); + } + + async validate(payload: JwtPayload) { + const user = await this.prisma.user.findUnique({ where: { id: payload.sub } }); + if (!user) throw new UnauthorizedException(); + + const membership = await this.prisma.teamMember.findUnique({ + where: { userId_teamId: { userId: user.id, teamId: payload.teamId } }, + }); + if (!membership) throw new UnauthorizedException('Not a member of this team'); + + return { ...user, teamId: payload.teamId, teamRole: payload.teamRole }; + } +} diff --git a/backend/src/modules/blocks/blocks.controller.ts b/backend/src/modules/blocks/blocks.controller.ts new file mode 100644 index 0000000..6a35768 --- /dev/null +++ b/backend/src/modules/blocks/blocks.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { TeamRole } from '@prisma/client'; +import { BlocksService } from './blocks.service'; +import { CreateBlockDto } from './dto/create-block.dto'; +import { UpdateBlockDto } from './dto/update-block.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; + +@ApiTags('blocks') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('blocks') +export class BlocksController { + constructor(private readonly service: BlocksService) {} + + @Get() @ApiOperation({ summary: 'List all blocks' }) + findAll(@Req() req: any) { return this.service.findAll(req.user.teamId); } + + @Post() @ApiOperation({ summary: 'Create block' }) @Roles(TeamRole.EDITOR) + create(@Body() dto: CreateBlockDto, @Req() req: any) { return this.service.create(dto, req.user.id, req.user.teamId); } + + @Patch(':id') @ApiOperation({ summary: 'Update block (creates version snapshot)' }) @Roles(TeamRole.EDITOR) + update(@Param('id') id: string, @Body() dto: UpdateBlockDto, @Req() req: any) { return this.service.update(id, dto, req.user.id, req.user.teamId); } + + @Delete(':id') @ApiOperation({ summary: 'Delete block (only if not in use)' }) @Roles(TeamRole.EDITOR) + remove(@Param('id') id: string, @Req() req: any) { return this.service.delete(id, req.user.teamId, req.user.id); } + + @Get(':id/versions') @ApiOperation({ summary: 'Block version history' }) + versions(@Param('id') id: string) { return this.service.getVersions(id); } + + @Get(':id/usage') @ApiOperation({ summary: 'Videos and templates using this block' }) + usage(@Param('id') id: string, @Req() req: any) { return this.service.getUsage(id, req.user.teamId); } +} diff --git a/backend/src/modules/blocks/blocks.module.ts b/backend/src/modules/blocks/blocks.module.ts new file mode 100644 index 0000000..96f9d6f --- /dev/null +++ b/backend/src/modules/blocks/blocks.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { BlocksService } from './blocks.service'; +import { BlocksController } from './blocks.controller'; +import { AuditModule } from '../../shared/audit/audit.module'; +import { QUEUES } from '../../queues/queues.constants'; + +@Module({ + imports: [AuditModule, BullModule.registerQueue({ name: QUEUES.RENDER })], + providers: [BlocksService], + controllers: [BlocksController], + exports: [BlocksService], +}) +export class BlocksModule {} diff --git a/backend/src/modules/blocks/blocks.service.ts b/backend/src/modules/blocks/blocks.service.ts new file mode 100644 index 0000000..3424197 --- /dev/null +++ b/backend/src/modules/blocks/blocks.service.ts @@ -0,0 +1,107 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { AuditService } from '../../shared/audit/audit.service'; +import { CreateBlockDto } from './dto/create-block.dto'; +import { UpdateBlockDto } from './dto/update-block.dto'; +import { QUEUES } from '../../queues/queues.constants'; + +@Injectable() +export class BlocksService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + @InjectQueue(QUEUES.RENDER) private readonly renderQueue: Queue, + ) {} + + findAll(teamId: string) { + return this.prisma.descriptionBlock.findMany({ where: { teamId }, orderBy: { name: 'asc' } }); + } + + async create(dto: CreateBlockDto, actorId: string, teamId: string) { + const block = await this.prisma.descriptionBlock.create({ + data: { + ...dto, + variableDefinitions: (dto.variableDefinitions ?? []) as any, + condition: dto.condition === null ? Prisma.JsonNull : (dto.condition as any), + teamId, + }, + }); + await this.audit.log(actorId, 'DescriptionBlock', block.id, 'create', null, block); + return block; + } + + async update(id: string, dto: UpdateBlockDto, actorId: string, teamId: string) { + const before = await this.prisma.descriptionBlock.findFirst({ where: { id, teamId } }); + if (!before) throw new NotFoundException(`Block ${id} not found`); + + await this.prisma.blockVersion.create({ + data: { blockId: id, version: before.version, contentSnapshot: before as any, createdBy: actorId }, + }); + + const updated = await this.prisma.descriptionBlock.update({ + where: { id }, + data: { + ...dto, + variableDefinitions: dto.variableDefinitions !== undefined ? (dto.variableDefinitions as any) : undefined, + condition: dto.condition === null ? Prisma.JsonNull : dto.condition !== undefined ? (dto.condition as any) : undefined, + version: { increment: 1 }, + }, + }); + + await this.audit.log(actorId, 'DescriptionBlock', id, 'update', before, updated); + await this.enqueueBlockRenders(id, teamId); + return updated; + } + + private async enqueueBlockRenders(blockId: string, teamId: string) { + const configs = await this.prisma.videoConfig.findMany({ + where: { + blockOrder: { array_contains: blockId } as any, + video: { channel: { teamId }, youtubeDeletedAt: null }, + }, + select: { videoId: true }, + }); + await Promise.all( + configs.map((c) => this.renderQueue.add('render', { videoId: c.videoId }, { jobId: `render-${c.videoId}` })), + ); + } + + getVersions(id: string) { + return this.prisma.blockVersion.findMany({ where: { blockId: id }, orderBy: { version: 'desc' } }); + } + + async delete(id: string, teamId: string, actorId: string) { + const block = await this.prisma.descriptionBlock.findFirst({ where: { id, teamId } }); + if (!block) throw new NotFoundException(`Block ${id} not found`); + const usage = await this.getUsage(id, teamId); + if (usage.videos.length > 0 || usage.templates.length > 0) { + throw new ConflictException( + `Block is used by ${usage.videos.length} video config(s) and ${usage.templates.length} template(s) and cannot be deleted`, + ); + } + await this.prisma.blockVersion.deleteMany({ where: { blockId: id } }); + await this.prisma.descriptionBlock.delete({ where: { id } }); + await this.audit.log(actorId, 'DescriptionBlock', id, 'delete', block, null); + return { deleted: true }; + } + + async getUsage(id: string, teamId: string) { + const [videoConfigs, templates] = await Promise.all([ + this.prisma.videoConfig.findMany({ + where: { blockOrder: { array_contains: id }, video: { channel: { teamId } } }, + select: { videoId: true, video: { select: { title: true } } }, + }), + this.prisma.template.findMany({ + where: { defaultBlocks: { array_contains: id }, teamId }, + select: { id: true, name: true }, + }), + ]); + return { + videos: videoConfigs.map((c) => ({ id: c.videoId, title: c.video.title })), + templates, + }; + } +} diff --git a/backend/src/modules/blocks/dto/create-block.dto.ts b/backend/src/modules/blocks/dto/create-block.dto.ts new file mode 100644 index 0000000..65b7189 --- /dev/null +++ b/backend/src/modules/blocks/dto/create-block.dto.ts @@ -0,0 +1,22 @@ +import { IsString, IsEnum, IsOptional, IsBoolean, IsArray } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { BlockType } from '@prisma/client'; + +export class BlockVariableDefinition { + name: string; + label: string; + description?: string; + defaultValue?: string; +} + +export class CreateBlockDto { + @ApiProperty() @IsString() name: string; + @ApiProperty({ enum: BlockType }) @IsEnum(BlockType) type: BlockType; + @ApiProperty() @IsString() content: string; + @ApiPropertyOptional() @IsOptional() @IsString() language?: string; + @ApiPropertyOptional() @IsOptional() @IsBoolean() compact?: boolean; + @ApiPropertyOptional() @IsOptional() @IsString() campaignId?: string; + @ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[]; + @ApiPropertyOptional({ type: [BlockVariableDefinition] }) @IsOptional() @IsArray() variableDefinitions?: BlockVariableDefinition[]; + @ApiPropertyOptional() @IsOptional() condition?: Record | null; +} diff --git a/backend/src/modules/blocks/dto/update-block.dto.ts b/backend/src/modules/blocks/dto/update-block.dto.ts new file mode 100644 index 0000000..bfada93 --- /dev/null +++ b/backend/src/modules/blocks/dto/update-block.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsOptional, IsBoolean, IsArray } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { BlockVariableDefinition } from './create-block.dto'; + +export class UpdateBlockDto { + @ApiPropertyOptional() @IsOptional() @IsString() name?: string; + @ApiPropertyOptional() @IsOptional() @IsString() content?: string; + @ApiPropertyOptional() @IsOptional() @IsString() language?: string; + @ApiPropertyOptional() @IsOptional() @IsBoolean() active?: boolean; + @ApiPropertyOptional() @IsOptional() @IsBoolean() compact?: boolean; + @ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[]; + @ApiPropertyOptional({ type: [BlockVariableDefinition] }) @IsOptional() @IsArray() variableDefinitions?: BlockVariableDefinition[]; + @ApiPropertyOptional() @IsOptional() condition?: Record | null; +} diff --git a/backend/src/modules/bulk-jobs/bulk-jobs.controller.ts b/backend/src/modules/bulk-jobs/bulk-jobs.controller.ts new file mode 100644 index 0000000..f4df680 --- /dev/null +++ b/backend/src/modules/bulk-jobs/bulk-jobs.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get, Post, Body, Param, Query, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { TeamRole } from '@prisma/client'; +import { BulkJobsService } from './bulk-jobs.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; + +@ApiTags('bulk-jobs') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('bulk-jobs') +export class BulkJobsController { + constructor(private readonly service: BulkJobsService) {} + + @Get() @ApiOperation({ summary: 'List bulk jobs for active team' }) + findAll(@Req() req: any, @Query('status') status?: string) { + return this.service.findAll(req.user.teamId, status); + } + + @Get(':id') @ApiOperation({ summary: 'Get bulk job with items' }) + findOne(@Param('id') id: string, @Req() req: any) { return this.service.findOne(id, req.user.teamId); } + + @Post(':id/rollback') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Rollback a completed bulk job' }) + rollback(@Param('id') id: string, @Req() req: any) { return this.service.rollback(id, req.user.id, req.user.teamId); } + + @Get('push-pending/preview') @ApiOperation({ summary: 'Preview all push-pending videos with field-level diffs' }) + previewPushPending( + @Req() req: any, + @Query('sort') sort?: string, + @Query('order') order?: string, + ) { return this.service.previewPushPending(req.user.teamId, sort, order as 'asc' | 'desc' | undefined); } + + @Post('push-pending') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Create a bulk push job for selected pending videos' }) + createPushPendingJob(@Req() req: any, @Body('videoIds') videoIds: string[]) { + return this.service.createPushPendingJob(req.user.teamId, req.user.id, videoIds); + } +} diff --git a/backend/src/modules/bulk-jobs/bulk-jobs.module.ts b/backend/src/modules/bulk-jobs/bulk-jobs.module.ts new file mode 100644 index 0000000..2397841 --- /dev/null +++ b/backend/src/modules/bulk-jobs/bulk-jobs.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { BulkJobsService } from './bulk-jobs.service'; +import { BulkJobsController } from './bulk-jobs.controller'; +import { AuditModule } from '../../shared/audit/audit.module'; +import { QUEUES } from '../../queues/queues.constants'; + +@Module({ + imports: [AuditModule, BullModule.registerQueue({ name: QUEUES.BULK_METADATA }), BullModule.registerQueue({ name: QUEUES.YOUTUBE_SYNC })], + providers: [BulkJobsService], + controllers: [BulkJobsController], + exports: [BulkJobsService], +}) +export class BulkJobsModule {} diff --git a/backend/src/modules/bulk-jobs/bulk-jobs.service.ts b/backend/src/modules/bulk-jobs/bulk-jobs.service.ts new file mode 100644 index 0000000..a49c64f --- /dev/null +++ b/backend/src/modules/bulk-jobs/bulk-jobs.service.ts @@ -0,0 +1,237 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { AuditService } from '../../shared/audit/audit.service'; +import { QUEUES } from '../../queues/queues.constants'; +import { computePendingChanges } from '../../shared/render-engine/pending-changes'; + +export type FieldDiff = { before: string | null; after: string | null }; +export type TagsDiff = { before: string[]; after: string[] }; +export type BoolDiff = { before: boolean; after: boolean }; + +export interface PushPendingPreviewItem { + videoId: string; + title: string; + thumbnailUrl: string | null; + firstSync: boolean; + changedFields: string[]; + diff: { + title?: FieldDiff; + description?: FieldDiff; + privacyStatus?: FieldDiff; + tags?: TagsDiff; + categoryId?: FieldDiff; + defaultLanguage?: FieldDiff; + defaultAudioLanguage?: FieldDiff; + selfDeclaredMadeForKids?: BoolDiff; + embeddable?: BoolDiff; + license?: FieldDiff; + recordingDate?: FieldDiff; + }; +} + +@Injectable() +export class BulkJobsService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + @InjectQueue(QUEUES.BULK_METADATA) private readonly bulkQueue: Queue, + @InjectQueue(QUEUES.YOUTUBE_SYNC) private readonly syncQueue: Queue, + ) {} + + findAll(teamId: string, status?: string) { + return this.prisma.bulkJob.findMany({ + where: { teamId, ...(status ? { status: status as any } : {}) }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findOne(id: string, teamId: string) { + const job = await this.prisma.bulkJob.findFirst({ + where: { id, teamId }, + include: { items: { include: { video: { select: { id: true, title: true } } } } }, + }); + if (!job) throw new NotFoundException(`BulkJob ${id} not found`); + return job; + } + + async rollback(id: string, actorId: string, teamId: string) { + const job = await this.findOne(id, teamId); + const doneItems = await this.prisma.bulkJobItem.findMany({ + where: { bulkJobId: id, status: 'done' }, + }); + + for (const item of doneItems) { + if (!item.beforeSnapshot) continue; + await this.prisma.video.update({ where: { id: item.videoId }, data: item.beforeSnapshot as any }); + await this.prisma.bulkJobItem.update({ where: { id: item.id }, data: { status: 'rolled_back' } }); + } + + const updated = await this.prisma.bulkJob.update({ where: { id }, data: { status: 'ROLLED_BACK' } }); + await this.audit.log(actorId, 'BulkJob', id, 'rollback', { status: job.status }, updated); + return updated; + } + + async enqueueItems(bulkJobId: string, teamId: string) { + const job = await this.findOne(bulkJobId, teamId); + const items = await this.prisma.bulkJobItem.findMany({ + where: { bulkJobId, status: 'pending' }, + }); + + await this.prisma.bulkJob.update({ where: { id: bulkJobId }, data: { status: 'RUNNING' } }); + + await Promise.all( + items.map((item) => this.bulkQueue.add('process-item', { bulkJobId, itemId: item.id })), + ); + return { enqueued: items.length }; + } + + async previewPushPending(teamId: string, sort = 'publishedAt', order: 'asc' | 'desc' = 'desc'): Promise { + const ALLOWED = new Set(['title', 'publishedAt', 'privacyStatus', 'lintStatus', 'lastSyncedAt']); + const sortField = ALLOWED.has(sort) ? sort : 'publishedAt'; + + const videos = await this.prisma.video.findMany({ + where: { channel: { teamId }, youtubeDeletedAt: null }, + select: { + id: true, title: true, thumbnailUrl: true, tags: true, categoryId: true, + privacyStatus: true, defaultLanguage: true, defaultAudioLanguage: true, + selfDeclaredMadeForKids: true, embeddable: true, license: true, recordingDate: true, + youtubeDescription: true, renderedDescription: true, + lastSyncedHash: true, youtubeSnapshot: true, + publishedAt: true, scheduledAt: true, createdAt: true, lintStatus: true, lastSyncedAt: true, + }, + }); + + const pending = videos.filter((v) => computePendingChanges(v)); + + // Mirror the same sort logic used by findAll so the modal order matches the tab order. + if (sortField === 'publishedAt') { + const now = new Date(); + pending.sort((a, b) => { + const aDate = (a.scheduledAt && a.scheduledAt > now ? a.scheduledAt : null) ?? a.publishedAt ?? a.createdAt; + const bDate = (b.scheduledAt && b.scheduledAt > now ? b.scheduledAt : null) ?? b.publishedAt ?? b.createdAt; + return order === 'desc' ? bDate.getTime() - aDate.getTime() : aDate.getTime() - bDate.getTime(); + }); + } else { + const dir = order === 'asc' ? 1 : -1; + pending.sort((a, b) => { + const av = (a as any)[sortField] ?? ''; + const bv = (b as any)[sortField] ?? ''; + if (av < bv) return -dir; + if (av > bv) return dir; + return 0; + }); + } + + return pending.map((v) => { + const snap = v.youtubeSnapshot as Record | null; + const allFields: string[] = ['title', 'description', 'privacyStatus', 'tags', 'categoryId', + 'defaultLanguage', 'defaultAudioLanguage', 'selfDeclaredMadeForKids', 'embeddable', 'license', 'recordingDate']; + + if (!snap) { + return { + videoId: v.id, title: v.title, thumbnailUrl: v.thumbnailUrl, + firstSync: true, changedFields: allFields, + diff: { + title: { before: null, after: v.title }, + description: { before: null, after: v.renderedDescription ?? '' }, + privacyStatus: { before: null, after: String(v.privacyStatus) }, + tags: { before: [], after: v.tags }, + categoryId: { before: null, after: v.categoryId ?? null }, + defaultLanguage: { before: null, after: v.defaultLanguage ?? null }, + defaultAudioLanguage: { before: null, after: v.defaultAudioLanguage ?? null }, + selfDeclaredMadeForKids: { before: false, after: v.selfDeclaredMadeForKids ?? false }, + embeddable: { before: true, after: v.embeddable ?? true }, + license: { before: null, after: v.license ?? null }, + recordingDate: { before: null, after: v.recordingDate ? v.recordingDate.toISOString().slice(0, 10) : null }, + }, + }; + } + + const diff: PushPendingPreviewItem['diff'] = {}; + const changedFields: string[] = []; + + if (snap.title !== v.title) { + changedFields.push('title'); + diff.title = { before: snap.title ?? null, after: v.title }; + } + const descBefore = v.youtubeDescription ?? ''; + const descAfter = v.renderedDescription ?? ''; + if (descBefore !== descAfter) { + changedFields.push('description'); + diff.description = { before: descBefore, after: descAfter }; + } + const snapPrivacy = String(snap.privacyStatus ?? '').toUpperCase(); + if (snapPrivacy !== String(v.privacyStatus)) { + changedFields.push('privacyStatus'); + diff.privacyStatus = { before: snap.privacyStatus ?? null, after: String(v.privacyStatus) }; + } + if (JSON.stringify([...(snap.tags ?? [])].sort()) !== JSON.stringify([...v.tags].sort())) { + changedFields.push('tags'); + diff.tags = { before: snap.tags ?? [], after: v.tags }; + } + if ((snap.categoryId ?? null) !== (v.categoryId ?? null)) { + changedFields.push('categoryId'); + diff.categoryId = { before: snap.categoryId ?? null, after: v.categoryId ?? null }; + } + if ((snap.defaultLanguage ?? null) !== (v.defaultLanguage ?? null)) { + changedFields.push('defaultLanguage'); + diff.defaultLanguage = { before: snap.defaultLanguage ?? null, after: v.defaultLanguage ?? null }; + } + if ((snap.defaultAudioLanguage ?? null) !== (v.defaultAudioLanguage ?? null)) { + changedFields.push('defaultAudioLanguage'); + diff.defaultAudioLanguage = { before: snap.defaultAudioLanguage ?? null, after: v.defaultAudioLanguage ?? null }; + } + if ((snap.selfDeclaredMadeForKids ?? false) !== (v.selfDeclaredMadeForKids ?? false)) { + changedFields.push('selfDeclaredMadeForKids'); + diff.selfDeclaredMadeForKids = { before: snap.selfDeclaredMadeForKids ?? false, after: v.selfDeclaredMadeForKids ?? false }; + } + if ((snap.embeddable ?? true) !== (v.embeddable ?? true)) { + changedFields.push('embeddable'); + diff.embeddable = { before: snap.embeddable ?? true, after: v.embeddable ?? true }; + } + if ((snap.license ?? null) !== (v.license ?? null)) { + changedFields.push('license'); + diff.license = { before: snap.license ?? null, after: v.license ?? null }; + } + const snapRd = snap.recordingDate ?? null; + const curRd = v.recordingDate ? v.recordingDate.toISOString().slice(0, 10) : null; + if (snapRd !== curRd) { + changedFields.push('recordingDate'); + diff.recordingDate = { before: snapRd, after: curRd }; + } + + return { videoId: v.id, title: v.title, thumbnailUrl: v.thumbnailUrl, firstSync: false, changedFields, diff }; + }); + } + + async createPushPendingJob(teamId: string, userId: string, videoIds: string[]): Promise<{ bulkJobId: string; count: number }> { + // Verify all requested videoIds belong to this team + const owned = await this.prisma.video.findMany({ + where: { id: { in: videoIds }, channel: { teamId } }, + select: { id: true }, + }); + const ownedIds = owned.map((v) => v.id); + + const job = await this.prisma.bulkJob.create({ + data: { + teamId, type: 'SYNC_PUSH', initiatedBy: userId, + filterSnapshot: { type: 'SYNC_PUSH', videoIds: ownedIds }, + targetIds: ownedIds, + totalCount: ownedIds.length, + status: 'CONFIRMED', + items: { create: ownedIds.map((id) => ({ videoId: id, status: 'pending' })) }, + }, + include: { items: { select: { id: true, videoId: true } } }, + }); + + await this.prisma.bulkJob.update({ where: { id: job.id }, data: { status: 'RUNNING' } }); + + for (const item of job.items) { + await this.syncQueue.add('sync', { videoId: item.videoId, bulkJobItemId: item.id }); + } + + return { bulkJobId: job.id, count: ownedIds.length }; + } +} diff --git a/backend/src/modules/calendar/calendar.controller.ts b/backend/src/modules/calendar/calendar.controller.ts new file mode 100644 index 0000000..f16d623 --- /dev/null +++ b/backend/src/modules/calendar/calendar.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger'; +import { CalendarService } from './calendar.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@ApiTags('calendar') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('calendar') +export class CalendarController { + constructor(private readonly service: CalendarService) {} + + @Get() + @ApiOperation({ summary: 'Get calendar entries' }) + @ApiQuery({ name: 'view', enum: ['month', 'week', 'agenda'] }) + @ApiQuery({ name: 'date', example: '2026-04' }) + getEntries(@Query('view') view: string = 'month', @Query('date') date: string) { + return this.service.getEntries(view, date ?? new Date().toISOString().slice(0, 7)); + } +} diff --git a/backend/src/modules/calendar/calendar.module.ts b/backend/src/modules/calendar/calendar.module.ts new file mode 100644 index 0000000..b94a11b --- /dev/null +++ b/backend/src/modules/calendar/calendar.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { CalendarService } from './calendar.service'; +import { CalendarController } from './calendar.controller'; + +@Module({ + providers: [CalendarService], + controllers: [CalendarController], +}) +export class CalendarModule {} diff --git a/backend/src/modules/calendar/calendar.service.ts b/backend/src/modules/calendar/calendar.service.ts new file mode 100644 index 0000000..cc34a4e --- /dev/null +++ b/backend/src/modules/calendar/calendar.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../shared/prisma/prisma.service'; + +@Injectable() +export class CalendarService { + constructor(private readonly prisma: PrismaService) {} + + async getEntries(view: string, date: string) { + const { start, end } = this.parseRange(view, date); + + const videos = await this.prisma.video.findMany({ + where: { + OR: [ + { scheduledAt: { gte: start, lte: end } }, + { publishedAt: { gte: start, lte: end } }, + ], + }, + include: { + template: { select: { id: true, name: true } }, + }, + orderBy: { scheduledAt: 'asc' }, + }); + + const allCollabIds = [...new Set(videos.flatMap((v) => (v.collaboratorIds as string[]) ?? []))]; + const collabs = allCollabIds.length > 0 + ? await this.prisma.collaborator.findMany({ + where: { id: { in: allCollabIds } }, + select: { id: true, name: true, youtubeLink: true }, + }) + : []; + const collabMap = new Map(collabs.map((c) => [c.id, c])); + + return videos.map((v) => ({ + videoId: v.id, + title: v.title, + date: v.scheduledAt ?? v.publishedAt, + templateName: v.template?.name, + collaborators: ((v.collaboratorIds as string[]) ?? []).map((id) => collabMap.get(id)).filter(Boolean), + lintStatus: v.lintStatus, + channelId: v.channelId, + privacyStatus: v.privacyStatus, + })); + } + + private parseRange(view: string, date: string): { start: Date; end: Date } { + const [year, month] = date.split('-').map(Number); + + if (view === 'month') { + const start = new Date(year, month - 1, 1); + const end = new Date(year, month, 0, 23, 59, 59); + return { start, end }; + } + + if (view === 'week') { + const base = new Date(year, month - 1, 1); + const start = new Date(base); + start.setDate(base.getDate() - base.getDay()); + const end = new Date(start); + end.setDate(start.getDate() + 6); + end.setHours(23, 59, 59); + return { start, end }; + } + + // agenda: next 30 days from date + const start = new Date(date); + const end = new Date(start); + end.setDate(start.getDate() + 30); + return { start, end }; + } +} diff --git a/backend/src/modules/campaigns/campaigns.controller.ts b/backend/src/modules/campaigns/campaigns.controller.ts new file mode 100644 index 0000000..8f20dde --- /dev/null +++ b/backend/src/modules/campaigns/campaigns.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { PrismaService } from '../../shared/prisma/prisma.service'; + +@ApiTags('campaigns') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('campaigns') +export class CampaignsController { + constructor(private readonly prisma: PrismaService) {} + + @Get() + findAll(@Req() req: any) { + return this.prisma.campaign.findMany({ + where: { teamId: req.user.teamId }, + orderBy: { startAt: 'desc' }, + select: { id: true, name: true, startAt: true, endAt: true, status: true }, + }); + } +} diff --git a/backend/src/modules/campaigns/campaigns.module.ts b/backend/src/modules/campaigns/campaigns.module.ts new file mode 100644 index 0000000..7ed7125 --- /dev/null +++ b/backend/src/modules/campaigns/campaigns.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { CampaignsController } from './campaigns.controller'; +import { PrismaModule } from '../../shared/prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [CampaignsController], +}) +export class CampaignsModule {} diff --git a/backend/src/modules/collaborators/collaborators.controller.ts b/backend/src/modules/collaborators/collaborators.controller.ts new file mode 100644 index 0000000..b71dcff --- /dev/null +++ b/backend/src/modules/collaborators/collaborators.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { TeamRole } from '@prisma/client'; +import { CollaboratorsService } from './collaborators.service'; +import { CreateCollaboratorDto } from './dto/create-collaborator.dto'; +import { UpdateCollaboratorDto } from './dto/update-collaborator.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; + +@ApiTags('collaborators') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('collaborators') +export class CollaboratorsController { + constructor(private readonly service: CollaboratorsService) {} + + @Get() + findAll(@Req() req: any) { return this.service.findAll(req.user.teamId); } + + @Post() @Roles(TeamRole.EDITOR) + create(@Body() dto: CreateCollaboratorDto, @Req() req: any) { return this.service.create(dto, req.user.id, req.user.teamId); } + + @Patch(':id') @Roles(TeamRole.EDITOR) + update(@Param('id') id: string, @Body() dto: UpdateCollaboratorDto, @Req() req: any) { return this.service.update(id, dto, req.user.id, req.user.teamId); } + + @Delete(':id') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Delete collaborator (only if not in use)' }) + remove(@Param('id') id: string, @Req() req: any) { return this.service.delete(id, req.user.teamId, req.user.id); } + + @Get(':id/videos') @ApiOperation({ summary: 'Videos linked to this collaborator' }) + videos(@Param('id') id: string, @Req() req: any) { return this.service.getVideos(id, req.user.teamId); } +} diff --git a/backend/src/modules/collaborators/collaborators.module.ts b/backend/src/modules/collaborators/collaborators.module.ts new file mode 100644 index 0000000..803f05e --- /dev/null +++ b/backend/src/modules/collaborators/collaborators.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { CollaboratorsService } from './collaborators.service'; +import { CollaboratorsController } from './collaborators.controller'; +import { AuditModule } from '../../shared/audit/audit.module'; +import { QUEUES } from '../../queues/queues.constants'; + +@Module({ + imports: [AuditModule, BullModule.registerQueue({ name: QUEUES.RENDER })], + providers: [CollaboratorsService], + controllers: [CollaboratorsController], + exports: [CollaboratorsService], +}) +export class CollaboratorsModule {} diff --git a/backend/src/modules/collaborators/collaborators.service.ts b/backend/src/modules/collaborators/collaborators.service.ts new file mode 100644 index 0000000..75ac364 --- /dev/null +++ b/backend/src/modules/collaborators/collaborators.service.ts @@ -0,0 +1,69 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { AuditService } from '../../shared/audit/audit.service'; +import { CreateCollaboratorDto } from './dto/create-collaborator.dto'; +import { UpdateCollaboratorDto } from './dto/update-collaborator.dto'; +import { QUEUES } from '../../queues/queues.constants'; + +@Injectable() +export class CollaboratorsService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + @InjectQueue(QUEUES.RENDER) private readonly renderQueue: Queue, + ) {} + + findAll(teamId: string) { + return this.prisma.collaborator.findMany({ where: { teamId }, orderBy: { name: 'asc' } }); + } + + async create(dto: CreateCollaboratorDto, actorId: string, teamId: string) { + const c = await this.prisma.collaborator.create({ data: { ...dto, teamId } }); + await this.audit.log(actorId, 'Collaborator', c.id, 'create', null, c); + return c; + } + + async update(id: string, dto: UpdateCollaboratorDto, actorId: string, teamId: string) { + const before = await this.prisma.collaborator.findFirst({ where: { id, teamId } }); + if (!before) throw new NotFoundException(`Collaborator ${id} not found`); + const updated = await this.prisma.collaborator.update({ where: { id }, data: dto }); + await this.audit.log(actorId, 'Collaborator', id, 'update', before, updated); + await this.enqueueCollaboratorRenders(id, teamId); + return updated; + } + + private async enqueueCollaboratorRenders(collaboratorId: string, teamId: string) { + const videos = await this.prisma.video.findMany({ + where: { + collaboratorIds: { array_contains: collaboratorId } as any, + channel: { teamId }, + youtubeDeletedAt: null, + }, + select: { id: true }, + }); + await Promise.all( + videos.map((v) => this.renderQueue.add('render', { videoId: v.id }, { jobId: `render-${v.id}` })), + ); + } + + async getVideos(id: string, teamId: string) { + return this.prisma.video.findMany({ + where: { collaboratorIds: { array_contains: id }, channel: { teamId } } as any, + select: { id: true, title: true }, + }); + } + + async delete(id: string, teamId: string, actorId: string) { + const collab = await this.prisma.collaborator.findFirst({ where: { id, teamId } }); + if (!collab) throw new NotFoundException(`Collaborator ${id} not found`); + const usageCount = await this.prisma.video.count({ + where: { collaboratorIds: { array_contains: id } } as any, + }); + if (usageCount > 0) throw new ConflictException(`Collaborator is used in ${usageCount} video(s) and cannot be deleted`); + await this.prisma.collaborator.delete({ where: { id } }); + await this.audit.log(actorId, 'Collaborator', id, 'delete', collab, null); + return { deleted: true }; + } +} diff --git a/backend/src/modules/collaborators/dto/create-collaborator.dto.ts b/backend/src/modules/collaborators/dto/create-collaborator.dto.ts new file mode 100644 index 0000000..818b30e --- /dev/null +++ b/backend/src/modules/collaborators/dto/create-collaborator.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsOptional, IsArray, IsBoolean } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateCollaboratorDto { + @ApiProperty() @IsString() name: string; + @ApiPropertyOptional() @IsOptional() @IsString() youtubeLink?: string; + @ApiPropertyOptional() @IsOptional() @IsString() twitchLink?: string; + @ApiPropertyOptional() @IsOptional() @IsString() instagramLink?: string; + @ApiPropertyOptional() @IsOptional() @IsString() tiktokLink?: string; + @ApiPropertyOptional() @IsOptional() @IsString() twitterLink?: string; + @ApiPropertyOptional() @IsOptional() @IsString() blueskyLink?: string; + @ApiPropertyOptional() @IsOptional() @IsString() discordHandle?: string; + @ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) aliases?: string[]; + @ApiPropertyOptional() @IsOptional() @IsString() notes?: string; +} diff --git a/backend/src/modules/collaborators/dto/update-collaborator.dto.ts b/backend/src/modules/collaborators/dto/update-collaborator.dto.ts new file mode 100644 index 0000000..979b33e --- /dev/null +++ b/backend/src/modules/collaborators/dto/update-collaborator.dto.ts @@ -0,0 +1,16 @@ +import { IsString, IsOptional, IsArray, IsBoolean } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateCollaboratorDto { + @ApiPropertyOptional() @IsOptional() @IsString() name?: string; + @ApiPropertyOptional() @IsOptional() @IsString() youtubeLink?: string; + @ApiPropertyOptional() @IsOptional() @IsString() twitchLink?: string; + @ApiPropertyOptional() @IsOptional() @IsString() instagramLink?: string; + @ApiPropertyOptional() @IsOptional() @IsString() tiktokLink?: string; + @ApiPropertyOptional() @IsOptional() @IsString() twitterLink?: string; + @ApiPropertyOptional() @IsOptional() @IsString() blueskyLink?: string; + @ApiPropertyOptional() @IsOptional() @IsString() discordHandle?: string; + @ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) aliases?: string[]; + @ApiPropertyOptional() @IsOptional() @IsBoolean() active?: boolean; + @ApiPropertyOptional() @IsOptional() @IsString() notes?: string; +} diff --git a/backend/src/modules/exports/exports.controller.ts b/backend/src/modules/exports/exports.controller.ts new file mode 100644 index 0000000..7a0f13a --- /dev/null +++ b/backend/src/modules/exports/exports.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Post, Body, Res, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { Response } from 'express'; +import { ExportsService } from './exports.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@ApiTags('exports') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('exports') +export class ExportsController { + constructor(private readonly service: ExportsService) {} + + @Post('csv') + @ApiOperation({ summary: 'Export videos as CSV' }) + async exportCsv(@Body() body: { videoIds?: string[]; savedViewId?: string }, @Res() res: Response) { + const csv = await this.service.exportCsv(body.videoIds, body.savedViewId); + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="studioflow-export-${Date.now()}.csv"`); + res.send(csv); + } + + @Post('json') + @ApiOperation({ summary: 'Export full workspace as JSON' }) + exportJson() { + return this.service.exportJson(); + } +} diff --git a/backend/src/modules/exports/exports.module.ts b/backend/src/modules/exports/exports.module.ts new file mode 100644 index 0000000..78389d8 --- /dev/null +++ b/backend/src/modules/exports/exports.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ExportsService } from './exports.service'; +import { ExportsController } from './exports.controller'; + +@Module({ + providers: [ExportsService], + controllers: [ExportsController], +}) +export class ExportsModule {} diff --git a/backend/src/modules/exports/exports.service.ts b/backend/src/modules/exports/exports.service.ts new file mode 100644 index 0000000..1731161 --- /dev/null +++ b/backend/src/modules/exports/exports.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { stringify } from 'csv-stringify/sync'; +import { PrismaService } from '../../shared/prisma/prisma.service'; + +@Injectable() +export class ExportsService { + constructor(private readonly prisma: PrismaService) {} + + async exportCsv(videoIds?: string[], savedViewId?: string): Promise { + let ids = videoIds ?? []; + + if (!ids.length && savedViewId) { + const view = await this.prisma.savedView.findUniqueOrThrow({ where: { id: savedViewId } }); + const videos = await this.prisma.video.findMany({ + where: view.queryJson as any, + select: { id: true }, + }); + ids = videos.map((v) => v.id); + } + + const where = ids.length > 0 ? { id: { in: ids } } : undefined; + const videos = await this.prisma.video.findMany({ + where, + include: { template: { select: { name: true } } }, + orderBy: { publishedAt: 'desc' }, + }); + + const rows = videos.map((v) => ({ + youtube_video_id: v.youtubeVideoId, + title: v.title, + tags: v.tags.join(','), + category_id: v.categoryId ?? '', + privacy_status: v.privacyStatus, + published_at: v.publishedAt?.toISOString() ?? '', + scheduled_at: v.scheduledAt?.toISOString() ?? '', + template: v.template?.name ?? '', + lint_status: v.lintStatus, + })); + + return stringify(rows, { header: true }); + } + + async exportJson(): Promise { + const [videos, videoConfigs, blocks, templates, collaborators, savedViews] = await Promise.all([ + this.prisma.video.findMany(), + this.prisma.videoConfig.findMany(), + this.prisma.descriptionBlock.findMany(), + this.prisma.template.findMany(), + this.prisma.collaborator.findMany(), + this.prisma.savedView.findMany(), + ]); + + return { + version: '1.0', + exportedAt: new Date().toISOString(), + videos, + videoConfigs, + blocks, + templates, + collaborators, + savedViews, + }; + } +} diff --git a/backend/src/modules/imports/imports.controller.ts b/backend/src/modules/imports/imports.controller.ts new file mode 100644 index 0000000..ca429f5 --- /dev/null +++ b/backend/src/modules/imports/imports.controller.ts @@ -0,0 +1,46 @@ +import { Controller, Post, Body, UploadedFile, UseInterceptors, UseGuards, Req } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiConsumes } from '@nestjs/swagger'; +import { TeamRole } from '@prisma/client'; +import { ImportsService } from './imports.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; + +@ApiTags('imports') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('imports') +export class ImportsController { + constructor(private readonly service: ImportsService) {} + + @Post('csv/preview') + @Roles(TeamRole.EDITOR) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Upload CSV and get validation report' }) + @UseInterceptors(FileInterceptor('file')) + previewCsv(@UploadedFile() file: Express.Multer.File, @Body() body: any, @Req() req: any) { + return this.service.previewCsv(file.buffer, body.mapping ? JSON.parse(body.mapping) : null, req.user.id, req.user.teamId); + } + + @Post('csv/commit') + @Roles(TeamRole.EDITOR) + @ApiOperation({ summary: 'Commit a previewed CSV import' }) + commitCsv(@Body() body: { importJobId: string }, @Req() req: any) { + return this.service.commitCsv(body.importJobId, req.user.teamId); + } + + @Post('json/preview') + @Roles(TeamRole.EDITOR) + @ApiOperation({ summary: 'Validate JSON workspace payload' }) + previewJson(@Body() body: any, @Req() req: any) { + return this.service.previewJson(body, req.user.id, req.user.teamId); + } + + @Post('json/commit') + @Roles(TeamRole.EDITOR) + @ApiOperation({ summary: 'Commit JSON workspace import' }) + commitJson(@Body() body: { importJobId: string; payload: any }, @Req() req: any) { + return this.service.commitJson(body.importJobId, body.payload, req.user.teamId); + } +} diff --git a/backend/src/modules/imports/imports.module.ts b/backend/src/modules/imports/imports.module.ts new file mode 100644 index 0000000..8673512 --- /dev/null +++ b/backend/src/modules/imports/imports.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { ImportsService } from './imports.service'; +import { ImportsController } from './imports.controller'; +import { AuditModule } from '../../shared/audit/audit.module'; +import { QUEUES } from '../../queues/queues.constants'; + +@Module({ + imports: [AuditModule, BullModule.registerQueue({ name: QUEUES.IMPORT })], + providers: [ImportsService], + controllers: [ImportsController], + exports: [ImportsService], +}) +export class ImportsModule {} diff --git a/backend/src/modules/imports/imports.service.ts b/backend/src/modules/imports/imports.service.ts new file mode 100644 index 0000000..c30fc5d --- /dev/null +++ b/backend/src/modules/imports/imports.service.ts @@ -0,0 +1,143 @@ +import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { parse } from 'csv-parse/sync'; +import { z } from 'zod'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { AuditService } from '../../shared/audit/audit.service'; +import { QUEUES } from '../../queues/queues.constants'; + +const CsvRowSchema = z.object({ + youtube_video_id: z.string().min(1), + title: z.string().optional(), + tags: z.string().optional(), + category_id: z.string().optional(), + privacy_status: z.enum(['PUBLIC', 'PRIVATE', 'UNLISTED']).optional(), + scheduled_at: z.string().optional(), + template_name: z.string().optional(), + collaborators: z.string().optional(), +}); + +@Injectable() +export class ImportsService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + @InjectQueue(QUEUES.IMPORT) private readonly importQueue: Queue, + ) {} + + async previewCsv(fileBuffer: Buffer, mappingJson: any, createdBy: string, teamId: string) { + const rows: any[] = parse(fileBuffer, { columns: true, skip_empty_lines: true, trim: true }); + + const validRows: any[] = []; + const errors: { row: number; field: string; message: string }[] = []; + + rows.forEach((row, i) => { + const result = CsvRowSchema.safeParse(row); + if (result.success) { + validRows.push(result.data); + } else { + result.error.errors.forEach((e) => { + errors.push({ row: i + 1, field: e.path.join('.'), message: e.message }); + }); + } + }); + + const importJob = await this.prisma.importJob.create({ + data: { + teamId, + type: 'csv', + sourceName: 'upload', + mappingJson: mappingJson ?? null, + validationReport: { validCount: validRows.length, errorCount: errors.length, errors }, + commitStatus: 'pending', + createdBy, + }, + }); + + return { importJobId: importJob.id, validRows: validRows.length, errors }; + } + + async commitCsv(importJobId: string, teamId: string) { + const job = await this.prisma.importJob.findFirstOrThrow({ where: { id: importJobId, teamId } }); + if (job.commitStatus !== 'pending') throw new BadRequestException('Already committed'); + + await this.importQueue.add('import', { importJobId }); + return { queued: true, importJobId }; + } + + async executeCommit(importJobId: string) { + const job = await this.prisma.importJob.findUniqueOrThrow({ where: { id: importJobId } }); + await this.prisma.importJob.update({ + where: { id: importJobId }, + data: { commitStatus: 'committed', committedAt: new Date() }, + }); + await this.audit.log(job.createdBy, 'ImportJob', importJobId, 'commit', null, { status: 'committed' }); + } + + async previewJson(payload: any, createdBy: string, teamId: string) { + const WorkspaceSchema = z.object({ + version: z.string(), + videos: z.array(z.any()).optional(), + blocks: z.array(z.any()).optional(), + templates: z.array(z.any()).optional(), + collaborators: z.array(z.any()).optional(), + }); + + const result = WorkspaceSchema.safeParse(payload); + if (!result.success) throw new BadRequestException(result.error.message); + + const importJob = await this.prisma.importJob.create({ + data: { + teamId, + type: 'json', + sourceName: 'upload', + validationReport: { valid: true }, + commitStatus: 'pending', + createdBy, + }, + }); + + return { importJobId: importJob.id, valid: true }; + } + + async commitJson(importJobId: string, payload: any, teamId: string) { + const job = await this.prisma.importJob.findFirstOrThrow({ where: { id: importJobId, teamId } }); + if (job.commitStatus !== 'pending') throw new BadRequestException('Already committed'); + + if (payload.collaborators) { + for (const c of payload.collaborators) { + await this.prisma.collaborator.upsert({ + where: { id: c.id }, + create: { ...c, teamId }, + update: c, + }); + } + } + if (payload.blocks) { + for (const b of payload.blocks) { + await this.prisma.descriptionBlock.upsert({ + where: { id: b.id }, + create: { ...b, teamId }, + update: b, + }); + } + } + if (payload.templates) { + for (const t of payload.templates) { + await this.prisma.template.upsert({ + where: { id: t.id }, + create: { ...t, teamId }, + update: t, + }); + } + } + + await this.prisma.importJob.update({ + where: { id: importJobId }, + data: { commitStatus: 'committed', committedAt: new Date() }, + }); + + return { committed: true }; + } +} diff --git a/backend/src/modules/linting/linting.controller.ts b/backend/src/modules/linting/linting.controller.ts new file mode 100644 index 0000000..2771e87 --- /dev/null +++ b/backend/src/modules/linting/linting.controller.ts @@ -0,0 +1,194 @@ +import { Controller, Post, Get, Patch, Param, Body, Query, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { IsArray, IsString, IsOptional, IsEnum } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { LintSeverity } from '@prisma/client'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { LintingService } from './linting.service'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { QUEUES } from '../../queues/queues.constants'; + +class BulkLintDto { + @IsArray() @IsString({ each: true }) videoIds: string[]; +} + +class BulkResolveDto { + @IsArray() @IsString({ each: true }) ids: string[]; +} + +class QueryLintResultsDto { + @ApiPropertyOptional({ enum: LintSeverity }) @IsOptional() @IsEnum(LintSeverity) severity?: LintSeverity; + @ApiPropertyOptional() @IsOptional() @IsString() ruleCode?: string; + @ApiPropertyOptional() @IsOptional() @IsString() videoId?: string; +} + +@ApiTags('lint') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('lint') +export class LintingController { + constructor( + private readonly service: LintingService, + private readonly prisma: PrismaService, + @InjectQueue(QUEUES.LINT) private readonly lintQueue: Queue, + ) {} + + @Post('videos/:id') + @ApiOperation({ summary: 'Lint a single video' }) + lintOne(@Param('id') id: string) { + return this.service.lintVideo(id); + } + + @Post('bulk') + @ApiOperation({ summary: 'Enqueue lint jobs for multiple videos' }) + async lintBulk(@Body() dto: BulkLintDto) { + const jobs = dto.videoIds.map((videoId) => + this.lintQueue.add('lint', { videoId }, { jobId: `lint-${videoId}` }), + ); + await Promise.all(jobs); + return { queued: dto.videoIds.length }; + } + + @Get('results') + @ApiOperation({ summary: 'Get open lint results scoped to the current user\'s team' }) + async getResults(@Query() query: QueryLintResultsDto, @Req() req: any) { + const teamId = await this.resolveTeamId(req.user.id); + + return this.prisma.lintResult.findMany({ + where: { + resolvedAt: null, + video: { channel: { teamId } }, + ...(query.severity ? { severity: query.severity } : {}), + ...(query.ruleCode ? { ruleCode: query.ruleCode } : {}), + ...(query.videoId ? { videoId: query.videoId } : {}), + }, + include: { video: { select: { id: true, title: true } } }, + orderBy: { createdAt: 'desc' }, + }); + } + + @Patch('results/:id/resolve') + @ApiOperation({ summary: 'Mark a lint result as resolved' }) + async resolveOne(@Param('id') id: string, @Req() req: any) { + const result = await this.prisma.lintResult.update({ + where: { id }, + data: { resolvedAt: new Date() }, + include: { video: { select: { id: true } } }, + }); + + // Recompute video lintStatus + await this.recomputeVideoStatus(result.video.id); + return result; + } + + @Post('results/bulk-resolve') + @ApiOperation({ summary: 'Mark multiple lint results as resolved' }) + async bulkResolve(@Body() dto: BulkResolveDto) { + await this.prisma.lintResult.updateMany({ + where: { id: { in: dto.ids } }, + data: { resolvedAt: new Date() }, + }); + + // Recompute lintStatus for all affected videos + const affected = await this.prisma.lintResult.findMany({ + where: { id: { in: dto.ids } }, + select: { videoId: true }, + distinct: ['videoId'], + }); + await Promise.all(affected.map((r) => this.recomputeVideoStatus(r.videoId))); + + return { resolved: dto.ids.length }; + } + + @Post('channel/:channelId') + @ApiOperation({ summary: 'Enqueue lint jobs for all videos in a channel' }) + async lintChannel(@Param('channelId') channelId: string, @Req() req: any) { + const teamId = await this.resolveTeamId(req.user.id); + const videoIds = await this.service.getChannelVideoIds(channelId, teamId); + await this.enqueueRerun(videoIds); + return { queued: videoIds.length }; + } + + @Post('team') + @ApiOperation({ summary: 'Enqueue lint jobs for all videos in the current team' }) + async lintTeam(@Req() req: any) { + const teamId = await this.resolveTeamId(req.user.id); + const videoIds = await this.service.getTeamVideoIds(teamId); + await this.enqueueRerun(videoIds); + return { queued: videoIds.length }; + } + + @Post('team/recompute-status') + @ApiOperation({ summary: 'Recompute lintStatus for all team videos from actual open results (heals stale status)' }) + async recomputeTeamStatus(@Req() req: any) { + const teamId = await this.resolveTeamId(req.user.id); + + // Aggregate open result severities per video in one query + const openResults = await this.prisma.lintResult.groupBy({ + by: ['videoId'], + where: { resolvedAt: null, video: { channel: { teamId } } }, + _max: { severity: true }, + }); + + // Build a map: videoId → worst severity + const severityMap = new Map(openResults.map((r) => [r.videoId, r._max.severity])); + + // All video IDs for the team + const allVideos = await this.prisma.video.findMany({ + where: { channel: { teamId } }, + select: { id: true, lintStatus: true }, + }); + + const updates = allVideos + .map((v) => { + const worst = severityMap.get(v.id) ?? null; + const status = worst === 'ERROR' ? 'ERROR' : worst === 'WARNING' ? 'WARNING' : 'OK'; + return status !== v.lintStatus ? { id: v.id, status } : null; + }) + .filter(Boolean) as { id: string; status: string }[]; + + await Promise.all( + updates.map((u) => + this.prisma.video.update({ where: { id: u.id }, data: { lintStatus: u.status as any } }), + ), + ); + + return { checked: allVideos.length, updated: updates.length }; + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + /** Enqueues re-run jobs with unique IDs so BullMQ never deduplicates them. */ + private enqueueRerun(videoIds: string[]) { + const ts = Date.now(); + return Promise.all( + videoIds.map((videoId) => + this.lintQueue.add('lint', { videoId }, { jobId: `lint-${videoId}-${ts}` }), + ), + ); + } + + private async resolveTeamId(userId: string): Promise { + const membership = await this.prisma.teamMember.findFirst({ + where: { userId }, + orderBy: { createdAt: 'asc' }, + }); + return membership?.teamId ?? ''; + } + + private async recomputeVideoStatus(videoId: string) { + const open = await this.prisma.lintResult.findMany({ + where: { videoId, resolvedAt: null }, + select: { severity: true }, + }); + const severities = open.map((r) => r.severity); + const status = severities.includes(LintSeverity.ERROR) + ? 'ERROR' + : severities.includes(LintSeverity.WARNING) + ? 'WARNING' + : 'OK'; + await this.prisma.video.update({ where: { id: videoId }, data: { lintStatus: status as any } }); + } +} diff --git a/backend/src/modules/linting/linting.module.ts b/backend/src/modules/linting/linting.module.ts new file mode 100644 index 0000000..16e7cc2 --- /dev/null +++ b/backend/src/modules/linting/linting.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { LintingService } from './linting.service'; +import { LintingController } from './linting.controller'; +import { QUEUES } from '../../queues/queues.constants'; + +@Module({ + imports: [BullModule.registerQueue({ name: QUEUES.LINT })], + providers: [LintingService], + controllers: [LintingController], + exports: [LintingService], +}) +export class LintingModule {} diff --git a/backend/src/modules/linting/linting.service.ts b/backend/src/modules/linting/linting.service.ts new file mode 100644 index 0000000..fb49a24 --- /dev/null +++ b/backend/src/modules/linting/linting.service.ts @@ -0,0 +1,112 @@ +import { Injectable, ForbiddenException } from '@nestjs/common'; +import { LintSeverity, LintStatus } from '@prisma/client'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { LintRule } from './rules/base.rule'; +import { TitleWeakRule } from './rules/title-weak.rule'; +import { TitleTooLongRule } from './rules/title-too-long.rule'; +import { DescMissingCtaRule } from './rules/desc-missing-cta.rule'; +import { DescMissingChaptersRule } from './rules/desc-missing-chapters.rule'; +import { DescEmptyPlaceholderRule } from './rules/desc-empty-placeholder.rule'; +import { DescDuplicateHashtagRule } from './rules/desc-duplicate-hashtag.rule'; +import { DescRequiredLinkMissingRule } from './rules/desc-required-link-missing.rule'; +import { DescOutdatedSponsorRule } from './rules/desc-outdated-sponsor.rule'; +import { RemoteConflictRule } from './rules/remote-conflict.rule'; + +@Injectable() +export class LintingService { + private readonly rules: LintRule[] = [ + new TitleWeakRule(), + new TitleTooLongRule(), + new DescMissingCtaRule(), + new DescMissingChaptersRule(), + new DescEmptyPlaceholderRule(), + new DescDuplicateHashtagRule(), + new DescRequiredLinkMissingRule(), + new DescOutdatedSponsorRule(), + new RemoteConflictRule(), + ]; + + constructor(private readonly prisma: PrismaService) {} + + async lintVideo(videoId: string) { + const video = await this.prisma.video.findUniqueOrThrow({ + where: { id: videoId }, + include: { + config: true, + template: true, + lintResults: { where: { resolvedAt: null } }, + channel: { include: { team: { select: { disabledLintRules: true } } } }, + }, + }); + + const disabledRules: string[] = (video as any).channel?.team?.disabledLintRules ?? []; + + // Attach campaign blocks for the outdated sponsor check + const blockIds: string[] = (video.config?.blockOrder as string[]) ?? []; + const campaignBlocks = await this.prisma.descriptionBlock.findMany({ + where: { id: { in: blockIds }, campaignId: { not: null } }, + include: { campaign: true }, + }); + (video as any)._campaignBlocks = campaignBlocks; + + const activeRules = disabledRules.length > 0 + ? this.rules.filter((r) => !disabledRules.includes(r.code)) + : this.rules; + + const issues = activeRules + .map((rule) => { + const issue = rule.check(video); + if (!issue) return null; + return { + videoId, + ruleCode: rule.code, + severity: rule.severity, + targetField: issue.targetField, + message: issue.message, + fixSuggestion: issue.fixSuggestion, + }; + }) + .filter(Boolean) as any[]; + + // Replace all unresolved lint results for this video + await this.prisma.$transaction([ + this.prisma.lintResult.deleteMany({ where: { videoId, resolvedAt: null } }), + ...(issues.length > 0 + ? [this.prisma.lintResult.createMany({ data: issues })] + : []), + this.prisma.video.update({ + where: { id: videoId }, + data: { lintStatus: this.computeStatus(issues.map((i) => i.severity)) }, + }), + ]); + + return issues; + } + + /** Returns all video IDs for a channel so the controller can enqueue them. */ + async getChannelVideoIds(channelId: string, teamId: string): Promise { + const channel = await this.prisma.channel.findFirst({ where: { id: channelId, teamId } }); + if (!channel) throw new ForbiddenException('Channel not found in this team'); + + const videos = await this.prisma.video.findMany({ + where: { channelId }, + select: { id: true }, + }); + return videos.map((v) => v.id); + } + + /** Returns all video IDs across all channels in a team. */ + async getTeamVideoIds(teamId: string): Promise { + const videos = await this.prisma.video.findMany({ + where: { channel: { teamId } }, + select: { id: true }, + }); + return videos.map((v) => v.id); + } + + private computeStatus(severities: LintSeverity[]): LintStatus { + if (severities.includes(LintSeverity.ERROR)) return LintStatus.ERROR; + if (severities.includes(LintSeverity.WARNING)) return LintStatus.WARNING; + return LintStatus.OK; + } +} diff --git a/backend/src/modules/linting/rules/base.rule.ts b/backend/src/modules/linting/rules/base.rule.ts new file mode 100644 index 0000000..81f16ab --- /dev/null +++ b/backend/src/modules/linting/rules/base.rule.ts @@ -0,0 +1,13 @@ +import { LintSeverity } from '@prisma/client'; + +export interface LintIssue { + message: string; + targetField?: string; + fixSuggestion?: string; +} + +export interface LintRule { + code: string; + severity: LintSeverity; + check(video: any): LintIssue | null; +} diff --git a/backend/src/modules/linting/rules/desc-duplicate-hashtag.rule.ts b/backend/src/modules/linting/rules/desc-duplicate-hashtag.rule.ts new file mode 100644 index 0000000..4cab4fc --- /dev/null +++ b/backend/src/modules/linting/rules/desc-duplicate-hashtag.rule.ts @@ -0,0 +1,26 @@ +import { LintSeverity } from '@prisma/client'; +import { LintRule, LintIssue } from './base.rule'; + +export class DescDuplicateHashtagRule implements LintRule { + code = 'DESC_DUPLICATE_HASHTAG'; + severity = LintSeverity.WARNING; + + check(video: any): LintIssue | null { + const desc: string = video.renderedDescription ?? ''; + const hashtags = desc.match(/#\w+/g)?.map((h) => h.toLowerCase()) ?? []; + const seen = new Set(); + const duplicates: string[] = []; + for (const tag of hashtags) { + if (seen.has(tag)) duplicates.push(tag); + else seen.add(tag); + } + if (duplicates.length > 0) { + return { + message: `Duplicate hashtags found: ${[...new Set(duplicates)].join(', ')}`, + targetField: 'description', + fixSuggestion: 'Remove duplicate hashtags from the description.', + }; + } + return null; + } +} diff --git a/backend/src/modules/linting/rules/desc-empty-placeholder.rule.ts b/backend/src/modules/linting/rules/desc-empty-placeholder.rule.ts new file mode 100644 index 0000000..fbeeb09 --- /dev/null +++ b/backend/src/modules/linting/rules/desc-empty-placeholder.rule.ts @@ -0,0 +1,22 @@ +import { LintSeverity } from '@prisma/client'; +import { LintRule, LintIssue } from './base.rule'; + +const PLACEHOLDER_REGEX = /\{[a-z_][a-z0-9_]*\}/g; + +export class DescEmptyPlaceholderRule implements LintRule { + code = 'DESC_EMPTY_PLACEHOLDER'; + severity = LintSeverity.ERROR; + + check(video: any): LintIssue | null { + const desc: string = video.renderedDescription ?? ''; + const unresolved = desc.match(PLACEHOLDER_REGEX); + if (unresolved?.length) { + return { + message: `Unresolved placeholders in description: ${unresolved.join(', ')}`, + targetField: 'description', + fixSuggestion: 'Provide values for all variable placeholders before publishing.', + }; + } + return null; + } +} diff --git a/backend/src/modules/linting/rules/desc-missing-chapters.rule.ts b/backend/src/modules/linting/rules/desc-missing-chapters.rule.ts new file mode 100644 index 0000000..8fcf145 --- /dev/null +++ b/backend/src/modules/linting/rules/desc-missing-chapters.rule.ts @@ -0,0 +1,23 @@ +import { LintSeverity } from '@prisma/client'; +import { LintRule, LintIssue } from './base.rule'; + +// Timestamps follow pattern 0:00 or 00:00 or 0:00:00 +const TIMESTAMP_REGEX = /\d{1,2}:\d{2}(:\d{2})?/g; + +export class DescMissingChaptersRule implements LintRule { + code = 'DESC_MISSING_CHAPTERS'; + severity = LintSeverity.WARNING; + + check(video: any): LintIssue | null { + const desc: string = video.renderedDescription ?? ''; + const matches = desc.match(TIMESTAMP_REGEX); + if (!matches || matches.length < 2) { + return { + message: 'No chapter timestamps found in description', + targetField: 'description', + fixSuggestion: 'Add a chapters block with at least two timestamps (e.g., 0:00 Intro).', + }; + } + return null; + } +} diff --git a/backend/src/modules/linting/rules/desc-missing-cta.rule.ts b/backend/src/modules/linting/rules/desc-missing-cta.rule.ts new file mode 100644 index 0000000..ec01437 --- /dev/null +++ b/backend/src/modules/linting/rules/desc-missing-cta.rule.ts @@ -0,0 +1,22 @@ +import { LintSeverity } from '@prisma/client'; +import { LintRule, LintIssue } from './base.rule'; + +const CTA_KEYWORDS = ['subscribe', 'abonnieren', 'follow', 'like', 'comment', 'cta']; + +export class DescMissingCtaRule implements LintRule { + code = 'DESC_MISSING_CTA'; + severity = LintSeverity.WARNING; + + check(video: any): LintIssue | null { + const desc: string = (video.renderedDescription ?? '').toLowerCase(); + const hasCta = CTA_KEYWORDS.some((k) => desc.includes(k)); + if (!hasCta) { + return { + message: 'No call-to-action found in description', + targetField: 'description', + fixSuggestion: 'Add a CTA block (subscribe, follow, etc.)', + }; + } + return null; + } +} diff --git a/backend/src/modules/linting/rules/desc-outdated-sponsor.rule.ts b/backend/src/modules/linting/rules/desc-outdated-sponsor.rule.ts new file mode 100644 index 0000000..d67c765 --- /dev/null +++ b/backend/src/modules/linting/rules/desc-outdated-sponsor.rule.ts @@ -0,0 +1,30 @@ +import { LintSeverity } from '@prisma/client'; +import { LintRule, LintIssue } from './base.rule'; + +export class DescOutdatedSponsorRule implements LintRule { + code = 'DESC_OUTDATED_SPONSOR_COPY'; + severity = LintSeverity.ERROR; + + check(video: any): LintIssue | null { + const config = video.config; + if (!config) return null; + + const blockOrder: string[] = (config.blockOrder as string[]) ?? []; + // blocks that come with a campaign reference + const campaignBlocks = (video._campaignBlocks ?? []) as Array<{ campaignId?: string; campaign?: { endAt?: Date; status: string } }>; + + for (const block of campaignBlocks) { + if (!blockOrder.includes((block as any).id)) continue; + if (!block.campaign) continue; + const { endAt, status } = block.campaign; + if (status !== 'active' || (endAt && new Date(endAt) < new Date())) { + return { + message: 'Sponsor/campaign block references an expired or inactive campaign', + targetField: 'description', + fixSuggestion: 'Update or remove the outdated sponsor block.', + }; + } + } + return null; + } +} diff --git a/backend/src/modules/linting/rules/desc-required-link-missing.rule.ts b/backend/src/modules/linting/rules/desc-required-link-missing.rule.ts new file mode 100644 index 0000000..ad6faee --- /dev/null +++ b/backend/src/modules/linting/rules/desc-required-link-missing.rule.ts @@ -0,0 +1,28 @@ +import { LintSeverity } from '@prisma/client'; +import { LintRule, LintIssue } from './base.rule'; + +export class DescRequiredLinkMissingRule implements LintRule { + code = 'DESC_REQUIRED_LINK_MISSING'; + severity = LintSeverity.ERROR; + + check(video: any): LintIssue | null { + const template = video.template; + if (!template) return null; + + const rules = (template.rules as any) ?? {}; + const requiredLinks: string[] = rules.requiredLinks ?? []; + if (requiredLinks.length === 0) return null; + + const desc: string = video.renderedDescription ?? ''; + const missing = requiredLinks.filter((link: string) => !desc.includes(link)); + + if (missing.length > 0) { + return { + message: `Required links missing from description: ${missing.join(', ')}`, + targetField: 'description', + fixSuggestion: 'Add the required links defined in the template rules.', + }; + } + return null; + } +} diff --git a/backend/src/modules/linting/rules/remote-conflict.rule.ts b/backend/src/modules/linting/rules/remote-conflict.rule.ts new file mode 100644 index 0000000..7a4e5fc --- /dev/null +++ b/backend/src/modules/linting/rules/remote-conflict.rule.ts @@ -0,0 +1,18 @@ +import { LintSeverity } from '@prisma/client'; +import { LintRule, LintIssue } from './base.rule'; + +export class RemoteConflictRule implements LintRule { + code = 'REMOTE_CONFLICT'; + severity = LintSeverity.ERROR; + + check(video: any): LintIssue | null { + if (video.remoteConflict) { + return { + message: 'Remote YouTube metadata has changed since last sync — local and remote are out of sync', + targetField: 'sync', + fixSuggestion: 'Review the remote changes and re-sync to resolve the conflict.', + }; + } + return null; + } +} diff --git a/backend/src/modules/linting/rules/title-too-long.rule.ts b/backend/src/modules/linting/rules/title-too-long.rule.ts new file mode 100644 index 0000000..8bc27eb --- /dev/null +++ b/backend/src/modules/linting/rules/title-too-long.rule.ts @@ -0,0 +1,19 @@ +import { LintSeverity } from '@prisma/client'; +import { LintRule, LintIssue } from './base.rule'; + +export class TitleTooLongRule implements LintRule { + code = 'TITLE_TOO_LONG'; + severity = LintSeverity.WARNING; + + check(video: any): LintIssue | null { + const title: string = video.title ?? ''; + if (title.length > 100) { + return { + message: `Title exceeds 100 characters (${title.length})`, + targetField: 'title', + fixSuggestion: 'Shorten the title to under 100 characters.', + }; + } + return null; + } +} diff --git a/backend/src/modules/linting/rules/title-weak.rule.ts b/backend/src/modules/linting/rules/title-weak.rule.ts new file mode 100644 index 0000000..22284cb --- /dev/null +++ b/backend/src/modules/linting/rules/title-weak.rule.ts @@ -0,0 +1,21 @@ +import { LintSeverity } from '@prisma/client'; +import { LintRule, LintIssue } from './base.rule'; + +const GENERIC_WORDS = ['video', 'test', 'untitled', 'new video', 'upload']; + +export class TitleWeakRule implements LintRule { + code = 'TITLE_WEAK'; + severity = LintSeverity.WARNING; + + check(video: any): LintIssue | null { + const title: string = video.title ?? ''; + if (title.length < 20 || GENERIC_WORDS.some((w) => title.toLowerCase().includes(w))) { + return { + message: `Title is too short or uses generic words: "${title}"`, + targetField: 'title', + fixSuggestion: 'Use a descriptive, specific title with at least 20 characters.', + }; + } + return null; + } +} diff --git a/backend/src/modules/playlists/playlists.controller.ts b/backend/src/modules/playlists/playlists.controller.ts new file mode 100644 index 0000000..c9bccfd --- /dev/null +++ b/backend/src/modules/playlists/playlists.controller.ts @@ -0,0 +1,48 @@ +import { Controller, Get, Post, Delete, Param, Query, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { PlaylistsService } from './playlists.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { TeamRole } from '@prisma/client'; + +@ApiTags('playlists') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('playlists') +export class PlaylistsController { + constructor(private readonly service: PlaylistsService) {} + + @Get('channel/:channelId') + @ApiOperation({ summary: 'List playlists for a channel (from DB)' }) + listForChannel(@Param('channelId') channelId: string, @Req() req: any) { + return this.service.listForChannel(channelId, req.user.teamId); + } + + @Post('channel/:channelId/sync') + @ApiOperation({ summary: 'Sync playlists from YouTube for a channel' }) + @Roles(TeamRole.EDITOR) + sync(@Param('channelId') channelId: string, @Req() req: any) { + return this.service.syncChannelPlaylists(channelId, req.user.teamId); + } + + @Get('video/:videoId') + @ApiOperation({ summary: 'List playlists a video belongs to' }) + listForVideo(@Param('videoId') videoId: string, @Req() req: any) { + return this.service.listForVideo(videoId, req.user.teamId); + } + + @Post('video/:videoId/add/:playlistId') + @ApiOperation({ summary: 'Add video to playlist' }) + @Roles(TeamRole.EDITOR) + add(@Param('videoId') videoId: string, @Param('playlistId') playlistId: string, @Req() req: any) { + return this.service.addVideoToPlaylist(videoId, playlistId, req.user.teamId); + } + + @Delete('video/:videoId/remove/:playlistId') + @ApiOperation({ summary: 'Remove video from playlist' }) + @Roles(TeamRole.EDITOR) + remove(@Param('videoId') videoId: string, @Param('playlistId') playlistId: string, @Req() req: any) { + return this.service.removeVideoFromPlaylist(videoId, playlistId, req.user.teamId); + } +} diff --git a/backend/src/modules/playlists/playlists.module.ts b/backend/src/modules/playlists/playlists.module.ts new file mode 100644 index 0000000..8179a2b --- /dev/null +++ b/backend/src/modules/playlists/playlists.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PlaylistsService } from './playlists.service'; +import { PlaylistsController } from './playlists.controller'; +import { YouTubeSyncModule } from '../youtube-sync/youtube-sync.module'; + +@Module({ + imports: [YouTubeSyncModule], + controllers: [PlaylistsController], + providers: [PlaylistsService], +}) +export class PlaylistsModule {} diff --git a/backend/src/modules/playlists/playlists.service.ts b/backend/src/modules/playlists/playlists.service.ts new file mode 100644 index 0000000..6459c76 --- /dev/null +++ b/backend/src/modules/playlists/playlists.service.ts @@ -0,0 +1,120 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { YouTubeApiClient } from '../youtube-sync/youtube-api.client'; + +@Injectable() +export class PlaylistsService { + constructor( + private readonly prisma: PrismaService, + private readonly ytApi: YouTubeApiClient, + ) {} + + async syncChannelPlaylists(channelId: string, teamId: string) { + const channel = await this.prisma.channel.findFirst({ where: { id: channelId, teamId } }); + if (!channel) throw new NotFoundException('Channel not found'); + + const ctx = { actionId: randomUUID(), actionType: 'playlist_sync' }; + + const existingPlaylists = await this.prisma.playlist.findMany({ + where: { channelId }, + select: { id: true, youtubePlaylistId: true, itemCount: true }, + }); + const existingMap = new Map(existingPlaylists.map((pl) => [pl.youtubePlaylistId, pl])); + + const ytPlaylists = await this.ytApi.listChannelPlaylists(channelId, ctx); + + // Upsert playlist metadata + for (const pl of ytPlaylists) { + await this.prisma.playlist.upsert({ + where: { youtubePlaylistId: pl.youtubePlaylistId }, + create: { channelId, ...pl }, + update: { title: pl.title, description: pl.description, itemCount: pl.itemCount, privacyStatus: pl.privacyStatus }, + }); + } + + const dbPlaylists = await this.prisma.playlist.findMany({ + where: { channelId }, + select: { id: true, youtubePlaylistId: true }, + }); + const playlistIdMap = new Map(dbPlaylists.map((pl) => [pl.youtubePlaylistId, pl.id])); + + for (const pl of ytPlaylists) { + const dbPlaylistId = playlistIdMap.get(pl.youtubePlaylistId); + if (!dbPlaylistId) continue; + + const existing = existingMap.get(pl.youtubePlaylistId); + if (existing && existing.itemCount === pl.itemCount) continue; + + const { ids: ytVideoIds } = await this.ytApi.listPlaylistVideoIds(channelId, pl.youtubePlaylistId, ctx); + + const matchingVideos = await this.prisma.video.findMany({ + where: { youtubeVideoId: { in: ytVideoIds }, channelId }, + select: { id: true }, + }); + + await this.prisma.videoPlaylist.deleteMany({ where: { playlistId: dbPlaylistId } }); + if (matchingVideos.length > 0) { + await this.prisma.videoPlaylist.createMany({ + data: matchingVideos.map((v) => ({ videoId: v.id, playlistId: dbPlaylistId })), + skipDuplicates: true, + }); + } + } + + return this.prisma.playlist.findMany({ where: { channelId } }); + } + + async listForChannel(channelId: string, teamId: string) { + const channel = await this.prisma.channel.findFirst({ where: { id: channelId, teamId } }); + if (!channel) throw new NotFoundException('Channel not found'); + return this.prisma.playlist.findMany({ where: { channelId }, orderBy: { title: 'asc' } }); + } + + async listForVideo(videoId: string, teamId: string) { + const video = await this.prisma.video.findFirst({ + where: { id: videoId, channel: { teamId } }, + include: { playlists: { include: { playlist: true } } }, + }); + if (!video) throw new NotFoundException('Video not found'); + return video.playlists.map((vp) => vp.playlist); + } + + async addVideoToPlaylist(videoId: string, playlistId: string, teamId: string) { + const video = await this.prisma.video.findFirst({ + where: { id: videoId, channel: { teamId } }, + include: { channel: true }, + }); + if (!video) throw new NotFoundException('Video not found'); + + const playlist = await this.prisma.playlist.findFirst({ where: { id: playlistId, channel: { teamId } } }); + if (!playlist) throw new NotFoundException('Playlist not found'); + + await this.ytApi.addVideoToPlaylist(video.youtubeVideoId, playlist.youtubePlaylistId, video.channelId, { actionId: randomUUID(), actionType: 'playlist_add' }); + + await this.prisma.videoPlaylist.upsert({ + where: { videoId_playlistId: { videoId, playlistId } }, + create: { videoId, playlistId }, + update: {}, + }); + + return { videoId, playlistId }; + } + + async removeVideoFromPlaylist(videoId: string, playlistId: string, teamId: string) { + const video = await this.prisma.video.findFirst({ + where: { id: videoId, channel: { teamId } }, + include: { channel: true }, + }); + if (!video) throw new NotFoundException('Video not found'); + + const playlist = await this.prisma.playlist.findFirst({ where: { id: playlistId, channel: { teamId } } }); + if (!playlist) throw new NotFoundException('Playlist not found'); + + await this.ytApi.removeVideoFromPlaylist(video.youtubeVideoId, playlist.youtubePlaylistId, video.channelId, { actionId: randomUUID(), actionType: 'playlist_remove' }); + + await this.prisma.videoPlaylist.deleteMany({ where: { videoId, playlistId } }); + + return { videoId, playlistId }; + } +} diff --git a/backend/src/modules/quota/quota.controller.ts b/backend/src/modules/quota/quota.controller.ts new file mode 100644 index 0000000..24dc9a9 --- /dev/null +++ b/backend/src/modules/quota/quota.controller.ts @@ -0,0 +1,108 @@ +import { Controller, Get, Query, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger'; +import { QuotaService } from '../../shared/quota/quota.service'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@ApiTags('quota') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('quota') +export class QuotaController { + constructor( + private readonly quota: QuotaService, + private readonly prisma: PrismaService, + ) {} + + @Get('today') + @ApiOperation({ summary: 'YouTube API quota status for today (scoped to active team)' }) + async today(@Req() req: any) { + const teamId: string = req.user.teamId; + const used = await this.quota.getTodayUsageForTeam(teamId); + const limit = this.quota.getLimit(); + const remaining = Math.max(0, limit - used); + const resetMs = this.quota.msUntilQuotaReset(); + const resetAt = new Date(Date.now() + resetMs).toISOString(); + + return { used, remaining, limit, resetAt, percentUsed: Math.round((used / limit) * 100) }; + } + + @Get('history') + @ApiOperation({ summary: 'Quota log entries scoped to the active team, newest first' }) + @ApiQuery({ name: 'days', required: false, description: 'How many days back to fetch (default 7)' }) + async history(@Req() req: any, @Query('days') days?: string) { + const teamId: string = req.user.teamId; + const daysBack = Math.min(parseInt(days ?? '7', 10) || 7, 90); + + const channels = await this.prisma.channel.findMany({ + where: { teamId }, + select: { id: true, name: true }, + }); + const channelIds = channels.map((c) => c.id); + const channelNameMap = Object.fromEntries(channels.map((c) => [c.id, c.name])); + + if (channelIds.length === 0) return { items: [], totalUnits: 0 }; + + const since = new Date(); + since.setDate(since.getDate() - daysBack); + + const logs = await this.prisma.quotaLog.findMany({ + where: { + channelId: { in: channelIds }, + createdAt: { gte: since }, + }, + include: { + video: { select: { id: true, title: true, youtubeVideoId: true } }, + }, + orderBy: { createdAt: 'desc' }, + take: 500, + }); + + // Collect YouTube playlist IDs stored in entityId so we can resolve their names + const playlistEntityIds = logs + .map((l) => l.entityId) + .filter((id): id is string => !!id && !id.startsWith('batch:')); + + const playlists = playlistEntityIds.length + ? await this.prisma.playlist.findMany({ + where: { youtubePlaylistId: { in: playlistEntityIds } }, + select: { youtubePlaylistId: true, title: true }, + }) + : []; + const playlistNameMap = Object.fromEntries(playlists.map((p) => [p.youtubePlaylistId, p.title])); + + const totalUnits = logs.reduce((sum, l) => sum + l.units, 0); + + const items = logs.map((l) => { + let entityLabel: string | null = null; + if (l.entityId) { + if (l.entityId.startsWith('batch:')) { + entityLabel = `Import batch of ${l.entityId.slice(6)} videos`; + } else if (l.entityId.startsWith('UU')) { + // YouTube auto-generated uploads playlist — resolve channel name for context + const chName = l.channelId ? (channelNameMap[l.channelId] ?? null) : null; + entityLabel = chName ? `Uploads playlist (${chName})` : 'Uploads playlist (channel import)'; + } else { + entityLabel = playlistNameMap[l.entityId] ?? l.entityId; + } + } + return { + id: l.id, + operation: l.operation, + units: l.units, + channelId: l.channelId, + channelName: l.channelId ? (channelNameMap[l.channelId] ?? null) : null, + videoId: l.videoId, + videoTitle: l.video?.title ?? null, + youtubeVideoId: l.video?.youtubeVideoId ?? null, + entityId: l.entityId, + entityLabel, + actionId: (l as any).actionId ?? null, + actionType: (l as any).actionType ?? null, + createdAt: l.createdAt.toISOString(), + }; + }); + + return { items, totalUnits }; + } +} diff --git a/backend/src/modules/saved-views/saved-views.controller.ts b/backend/src/modules/saved-views/saved-views.controller.ts new file mode 100644 index 0000000..4bae1ef --- /dev/null +++ b/backend/src/modules/saved-views/saved-views.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { TeamRole } from '@prisma/client'; +import { SavedViewsService } from './saved-views.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; + +@ApiTags('saved-views') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('saved-views') +export class SavedViewsController { + constructor(private readonly service: SavedViewsService) {} + + @Get() + findAll(@Req() req: any) { return this.service.findAll(req.user.teamId); } + + @Get('tabs') + findTabs(@Req() req: any) { return this.service.findTabs(req.user.teamId); } + + @Post() @Roles(TeamRole.EDITOR) + create(@Body() body: any, @Req() req: any) { + return this.service.create({ ...body, ownerId: req.user.id, teamId: req.user.teamId }, req.user.id); + } + + @Patch(':id') @Roles(TeamRole.EDITOR) + update(@Param('id') id: string, @Body() body: any, @Req() req: any) { + return this.service.update(id, body, req.user.teamId, req.user.id); + } + + @Delete(':id') @Roles(TeamRole.ADMIN) + remove(@Param('id') id: string, @Req() req: any) { return this.service.delete(id, req.user.teamId, req.user.id); } + + @Post(':id/execute') @ApiOperation({ summary: 'Execute saved view and return matching video IDs' }) + execute(@Param('id') id: string, @Req() req: any) { return this.service.execute(id, req.user.teamId); } +} diff --git a/backend/src/modules/saved-views/saved-views.module.ts b/backend/src/modules/saved-views/saved-views.module.ts new file mode 100644 index 0000000..1ce1307 --- /dev/null +++ b/backend/src/modules/saved-views/saved-views.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SavedViewsService } from './saved-views.service'; +import { SavedViewsController } from './saved-views.controller'; +import { AuditModule } from '../../shared/audit/audit.module'; + +@Module({ + imports: [AuditModule], + providers: [SavedViewsService], + controllers: [SavedViewsController], + exports: [SavedViewsService], +}) +export class SavedViewsModule {} diff --git a/backend/src/modules/saved-views/saved-views.service.ts b/backend/src/modules/saved-views/saved-views.service.ts new file mode 100644 index 0000000..b7070ea --- /dev/null +++ b/backend/src/modules/saved-views/saved-views.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { AuditService } from '../../shared/audit/audit.service'; + +@Injectable() +export class SavedViewsService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} + + findAll(teamId: string) { + return this.prisma.savedView.findMany({ where: { teamId }, orderBy: { name: 'asc' } }); + } + + findTabs(teamId: string) { + return this.prisma.savedView.findMany({ + where: { teamId, pinnedAsTab: true }, + orderBy: [{ tabOrder: 'asc' }, { name: 'asc' }], + }); + } + + async create(data: any, actorId: string) { + const view = await this.prisma.savedView.create({ data }); + await this.audit.log(actorId, 'SavedView', view.id, 'create', null, view); + return view; + } + + async update(id: string, data: any, teamId: string, actorId: string) { + const before = await this.prisma.savedView.findFirst({ where: { id, teamId } }); + await this.prisma.savedView.updateMany({ where: { id, teamId }, data }); + const after = before ? { ...before, ...data } : null; + await this.audit.log(actorId, 'SavedView', id, 'update', before, after); + } + + async delete(id: string, teamId: string, actorId: string) { + const before = await this.prisma.savedView.findFirst({ where: { id, teamId } }); + await this.prisma.savedView.deleteMany({ where: { id, teamId } }); + await this.audit.log(actorId, 'SavedView', id, 'delete', before, null); + } + + async execute(id: string, teamId: string) { + const view = await this.prisma.savedView.findFirstOrThrow({ where: { id, teamId } }); + let query = view.queryJson as any; + + if (JSON.stringify(query).includes('__MONTH_START__')) { + const now = new Date(); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + query = JSON.parse(JSON.stringify(query).replace('"__MONTH_START__"', `"${monthStart.toISOString()}"`)); + } + + const videos = await this.prisma.video.findMany({ + where: { ...query, channel: { teamId } }, + select: { id: true }, + }); + return { viewId: id, videoIds: videos.map((v) => v.id), count: videos.length }; + } +} diff --git a/backend/src/modules/team-variables/dto/upsert-team-variable.dto.ts b/backend/src/modules/team-variables/dto/upsert-team-variable.dto.ts new file mode 100644 index 0000000..ecca12e --- /dev/null +++ b/backend/src/modules/team-variables/dto/upsert-team-variable.dto.ts @@ -0,0 +1,7 @@ +import { IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UpsertTeamVariableDto { + @ApiProperty() @IsString() name: string; + @ApiProperty() @IsString() value: string; +} diff --git a/backend/src/modules/team-variables/team-variables.controller.ts b/backend/src/modules/team-variables/team-variables.controller.ts new file mode 100644 index 0000000..8f443d9 --- /dev/null +++ b/backend/src/modules/team-variables/team-variables.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { TeamRole } from '@prisma/client'; +import { TeamVariablesService } from './team-variables.service'; +import { UpsertTeamVariableDto } from './dto/upsert-team-variable.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; + +@ApiTags('team-variables') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('team-variables') +export class TeamVariablesController { + constructor(private readonly service: TeamVariablesService) {} + + @Get() @ApiOperation({ summary: 'List all team variables' }) + findAll(@Req() req: any) { return this.service.findAll(req.user.teamId); } + + @Post() @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Create a team variable' }) + create(@Body() dto: UpsertTeamVariableDto, @Req() req: any) { return this.service.create(dto, req.user.teamId, req.user.id); } + + @Patch(':id') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Update a team variable' }) + update(@Param('id') id: string, @Body() dto: UpsertTeamVariableDto, @Req() req: any) { + return this.service.update(id, dto, req.user.teamId, req.user.id); + } + + @Get(':id/usage') @ApiOperation({ summary: 'Blocks that reference this variable' }) + usage(@Param('id') id: string, @Req() req: any) { return this.service.getUsage(id, req.user.teamId); } + + @Delete(':id') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Delete a team variable' }) + remove(@Param('id') id: string, @Req() req: any) { return this.service.remove(id, req.user.teamId, req.user.id); } +} diff --git a/backend/src/modules/team-variables/team-variables.module.ts b/backend/src/modules/team-variables/team-variables.module.ts new file mode 100644 index 0000000..9236233 --- /dev/null +++ b/backend/src/modules/team-variables/team-variables.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { TeamVariablesService } from './team-variables.service'; +import { TeamVariablesController } from './team-variables.controller'; +import { AuditModule } from '../../shared/audit/audit.module'; +import { QUEUES } from '../../queues/queues.constants'; + +@Module({ + imports: [AuditModule, BullModule.registerQueue({ name: QUEUES.RENDER })], + providers: [TeamVariablesService], + controllers: [TeamVariablesController], + exports: [TeamVariablesService], +}) +export class TeamVariablesModule {} diff --git a/backend/src/modules/team-variables/team-variables.service.ts b/backend/src/modules/team-variables/team-variables.service.ts new file mode 100644 index 0000000..596a8da --- /dev/null +++ b/backend/src/modules/team-variables/team-variables.service.ts @@ -0,0 +1,76 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { AuditService } from '../../shared/audit/audit.service'; +import { UpsertTeamVariableDto } from './dto/upsert-team-variable.dto'; +import { QUEUES } from '../../queues/queues.constants'; + +@Injectable() +export class TeamVariablesService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + @InjectQueue(QUEUES.RENDER) private readonly renderQueue: Queue, + ) {} + + findAll(teamId: string) { + return this.prisma.teamVariable.findMany({ where: { teamId }, orderBy: { name: 'asc' } }); + } + + async create(dto: UpsertTeamVariableDto, teamId: string, actorId: string) { + const v = await this.prisma.teamVariable.create({ data: { ...dto, teamId } }); + await this.audit.log(actorId, 'TeamVariable', v.id, 'create', null, v); + await this.enqueueTeamRenders(teamId); + return v; + } + + async update(id: string, dto: Partial, teamId: string, actorId: string) { + const existing = await this.prisma.teamVariable.findFirst({ where: { id, teamId } }); + if (!existing) throw new NotFoundException(`Variable ${id} not found`); + const updated = await this.prisma.teamVariable.update({ where: { id }, data: dto }); + await this.audit.log(actorId, 'TeamVariable', id, 'update', existing, updated); + await this.enqueueTeamRenders(teamId); + return updated; + } + + async remove(id: string, teamId: string, actorId: string) { + const existing = await this.prisma.teamVariable.findFirst({ where: { id, teamId } }); + if (!existing) throw new NotFoundException(`Variable ${id} not found`); + await this.prisma.teamVariable.delete({ where: { id } }); + await this.audit.log(actorId, 'TeamVariable', id, 'delete', existing, null); + await this.enqueueTeamRenders(teamId); + return { deleted: true }; + } + + private async enqueueTeamRenders(teamId: string) { + const videos = await this.prisma.video.findMany({ + where: { channel: { teamId }, youtubeDeletedAt: null }, + select: { id: true }, + }); + await Promise.all( + videos.map((v) => this.renderQueue.add('render', { videoId: v.id }, { jobId: `render-${v.id}` })), + ); + } + + async getUsage(id: string, teamId: string) { + const variable = await this.prisma.teamVariable.findFirst({ where: { id, teamId } }); + if (!variable) throw new NotFoundException(`Variable ${id} not found`); + const blocks = await this.prisma.descriptionBlock.findMany({ + where: { teamId, content: { contains: `{${variable.name}}` } }, + select: { id: true, name: true }, + orderBy: { name: 'asc' }, + }); + return { blocks }; + } + + async getMap(teamId: string): Promise> { + const vars = await this.findAll(teamId); + return Object.fromEntries(vars.map((v) => [v.name, v.value])); + } + + async getDateFormat(teamId: string): Promise { + const team = await this.prisma.team.findUnique({ where: { id: teamId }, select: { dateFormat: true } }); + return team?.dateFormat ?? null; + } +} diff --git a/backend/src/modules/teams/teams.controller.ts b/backend/src/modules/teams/teams.controller.ts new file mode 100644 index 0000000..05d1937 --- /dev/null +++ b/backend/src/modules/teams/teams.controller.ts @@ -0,0 +1,89 @@ +import { Controller, Get, Post, Patch, Delete, Param, Body, Query, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger'; +import { TeamRole } from '@prisma/client'; +import { TeamsService } from './teams.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@ApiTags('teams') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('teams') +export class TeamsController { + constructor(private readonly service: TeamsService) {} + + @Get('mine') + @ApiOperation({ summary: 'List all teams the current user belongs to' }) + listMine(@Req() req: any) { + return this.service.listMyTeams(req.user.id); + } + + @Get(':teamId') + @ApiOperation({ summary: 'Get team details, members, and channels' }) + getTeam(@Param('teamId') teamId: string, @Req() req: any) { + return this.service.getTeam(teamId, req.user.id); + } + + @Get(':teamId/channels') + @ApiOperation({ summary: 'List connected YouTube channels for a team' }) + listChannels(@Param('teamId') teamId: string, @Req() req: any) { + return this.service.listChannels(teamId, req.user.id); + } + + @Post(':teamId/members') + @ApiOperation({ summary: 'Invite a user to the team by email (ADMIN+)' }) + invite( + @Param('teamId') teamId: string, + @Body() body: { email: string; role: TeamRole }, + @Req() req: any, + ) { + return this.service.inviteMember(teamId, req.user.id, body.email, body.role); + } + + @Patch(':teamId/members/:userId') + @ApiOperation({ summary: "Change a member's role (ADMIN+)" }) + updateRole( + @Param('teamId') teamId: string, + @Param('userId') userId: string, + @Body() body: { role: TeamRole }, + @Req() req: any, + ) { + return this.service.updateMemberRole(teamId, req.user.id, userId, body.role); + } + + @Get(':teamId/settings') + @ApiOperation({ summary: 'Get team-level render settings' }) + getSettings(@Param('teamId') teamId: string, @Req() req: any) { + return this.service.getSettings(teamId, req.user.id); + } + + @Patch(':teamId/settings') + @ApiOperation({ summary: 'Update team-level render settings (ADMIN+)' }) + updateSettings( + @Param('teamId') teamId: string, + @Body() body: { dateFormat?: string | null; timezone?: string; publishingSchedule?: any[] | null; showCanvaLink?: boolean; disabledLintRules?: string[]; showDeletedVideos?: boolean; conflictDetectionEnabled?: boolean; conflictDetectionBatchSize?: number; conflictDetectionMinAgeDays?: number }, + @Req() req: any, + ) { + return this.service.updateSettings(teamId, req.user.id, body); + } + + @Get(':teamId/next-publish-slot') + @ApiOperation({ summary: 'Find the next free publishing slot for a channel based on the team schedule' }) + @ApiQuery({ name: 'channelId', required: true }) + nextPublishSlot( + @Param('teamId') teamId: string, + @Query('channelId') channelId: string, + @Req() req: any, + ) { + return this.service.findNextFreeSlot(teamId, req.user.id, channelId); + } + + @Delete(':teamId/members/:userId') + @ApiOperation({ summary: 'Remove a member from the team (ADMIN+)' }) + removeMember( + @Param('teamId') teamId: string, + @Param('userId') userId: string, + @Req() req: any, + ) { + return this.service.removeMember(teamId, req.user.id, userId); + } +} diff --git a/backend/src/modules/teams/teams.module.ts b/backend/src/modules/teams/teams.module.ts new file mode 100644 index 0000000..9d65ef8 --- /dev/null +++ b/backend/src/modules/teams/teams.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TeamsService } from './teams.service'; +import { TeamsController } from './teams.controller'; +import { AuditModule } from '../../shared/audit/audit.module'; + +@Module({ + imports: [AuditModule], + controllers: [TeamsController], + providers: [TeamsService], + exports: [TeamsService], +}) +export class TeamsModule {} diff --git a/backend/src/modules/teams/teams.service.ts b/backend/src/modules/teams/teams.service.ts new file mode 100644 index 0000000..d45cbe2 --- /dev/null +++ b/backend/src/modules/teams/teams.service.ts @@ -0,0 +1,344 @@ +import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { AuditService } from '../../shared/audit/audit.service'; +import { TeamRole, Prisma } from '@prisma/client'; + +export interface PublishingSlot { + days: number[]; // 0=Sun … 6=Sat; empty array = every day + time: string; // "HH:MM" in team timezone (24h) +} + +@Injectable() +export class TeamsService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} + + async listMyTeams(userId: string) { + return this.prisma.teamMember.findMany({ + where: { userId }, + include: { team: { include: { channels: { select: { id: true, name: true, youtubeChannelId: true } } } } }, + orderBy: { createdAt: 'asc' }, + }); + } + + async getTeam(teamId: string, userId: string) { + await this.assertMember(teamId, userId); + return this.prisma.team.findUniqueOrThrow({ + where: { id: teamId }, + include: { + members: { include: { user: { select: { id: true, email: true, name: true } } } }, + channels: { select: { id: true, name: true, youtubeChannelId: true, createdAt: true } }, + }, + }); + } + + async listChannels(teamId: string, userId: string) { + await this.assertMember(teamId, userId); + return this.prisma.channel.findMany({ + where: { teamId }, + select: { id: true, name: true, youtubeChannelId: true, uploadsPlaylistId: true, connectedBy: true, createdAt: true }, + orderBy: { createdAt: 'asc' }, + }); + } + + async inviteMember(teamId: string, actorId: string, email: string, role: TeamRole) { + await this.assertRole(teamId, actorId, TeamRole.ADMIN); + if (role === TeamRole.OWNER) throw new ForbiddenException('Cannot assign OWNER role'); + const invitee = await this.prisma.user.findUnique({ where: { email } }); + if (!invitee) throw new NotFoundException(`No user found with email ${email}`); + + const member = await this.prisma.teamMember.upsert({ + where: { userId_teamId: { userId: invitee.id, teamId } }, + create: { userId: invitee.id, teamId, role }, + update: { role }, + }); + await this.audit.log(actorId, 'TeamMember', invitee.id, 'invite', null, { email, role, teamId }); + return member; + } + + async updateMemberRole(teamId: string, actorId: string, targetUserId: string, role: TeamRole) { + await this.assertRole(teamId, actorId, TeamRole.ADMIN); + const target = await this.prisma.teamMember.findUnique({ + where: { userId_teamId: { userId: targetUserId, teamId } }, + }); + if (!target) throw new NotFoundException('Member not found'); + if (target.role === TeamRole.OWNER) throw new ForbiddenException('Cannot change OWNER role'); + if (role === TeamRole.OWNER) throw new ForbiddenException('Cannot assign OWNER role'); + const updated = await this.prisma.teamMember.update({ + where: { userId_teamId: { userId: targetUserId, teamId } }, + data: { role }, + }); + await this.audit.log(actorId, 'TeamMember', targetUserId, 'update', { role: target.role }, { role }); + return updated; + } + + async removeMember(teamId: string, actorId: string, targetUserId: string) { + await this.assertRole(teamId, actorId, TeamRole.ADMIN); + const target = await this.prisma.teamMember.findUnique({ + where: { userId_teamId: { userId: targetUserId, teamId } }, + }); + if (!target) throw new NotFoundException('Member not found'); + if (target.role === TeamRole.OWNER) throw new ForbiddenException('Cannot remove the team owner'); + await this.prisma.teamMember.delete({ + where: { userId_teamId: { userId: targetUserId, teamId } }, + }); + await this.audit.log(actorId, 'TeamMember', targetUserId, 'delete', { role: target.role }, null); + } + + async getSettings(teamId: string, userId: string) { + await this.assertMember(teamId, userId); + const team = await this.prisma.team.findUniqueOrThrow({ + where: { id: teamId }, + select: { dateFormat: true, timezone: true, publishingSchedule: true, showCanvaLink: true, disabledLintRules: true, showDeletedVideos: true, conflictDetectionEnabled: true, conflictDetectionBatchSize: true, conflictDetectionMinAgeDays: true }, + }); + return { + dateFormat: team.dateFormat ?? null, + timezone: team.timezone, + publishingSchedule: (team.publishingSchedule as PublishingSlot[] | null) ?? null, + showCanvaLink: team.showCanvaLink, + disabledLintRules: team.disabledLintRules, + showDeletedVideos: team.showDeletedVideos, + conflictDetectionEnabled: team.conflictDetectionEnabled, + conflictDetectionBatchSize: team.conflictDetectionBatchSize, + conflictDetectionMinAgeDays: team.conflictDetectionMinAgeDays, + }; + } + + async updateSettings( + teamId: string, + userId: string, + data: { dateFormat?: string | null; timezone?: string; publishingSchedule?: PublishingSlot[] | null; showCanvaLink?: boolean; disabledLintRules?: string[]; showDeletedVideos?: boolean; conflictDetectionEnabled?: boolean; conflictDetectionBatchSize?: number; conflictDetectionMinAgeDays?: number }, + ) { + await this.assertRole(teamId, userId, TeamRole.ADMIN); + + if (data.publishingSchedule != null) { + this.validateSchedule(data.publishingSchedule); + } + if (data.timezone != null) { + this.validateTimezone(data.timezone); + } + if (data.conflictDetectionBatchSize !== undefined && (data.conflictDetectionBatchSize < 1 || data.conflictDetectionBatchSize > 500)) { + throw new BadRequestException('conflictDetectionBatchSize must be between 1 and 500'); + } + if (data.conflictDetectionMinAgeDays !== undefined && data.conflictDetectionMinAgeDays < 0) { + throw new BadRequestException('conflictDetectionMinAgeDays must be >= 0'); + } + + const team = await this.prisma.team.update({ + where: { id: teamId }, + data: { + ...(data.dateFormat !== undefined ? { dateFormat: data.dateFormat ?? null } : {}), + ...(data.timezone !== undefined ? { timezone: data.timezone } : {}), + ...(data.publishingSchedule !== undefined ? { publishingSchedule: data.publishingSchedule ? (data.publishingSchedule as unknown as Prisma.InputJsonValue) : Prisma.JsonNull } : {}), + ...(data.showCanvaLink !== undefined ? { showCanvaLink: data.showCanvaLink } : {}), + ...(data.disabledLintRules !== undefined ? { disabledLintRules: data.disabledLintRules } : {}), + ...(data.showDeletedVideos !== undefined ? { showDeletedVideos: data.showDeletedVideos } : {}), + ...(data.conflictDetectionEnabled !== undefined ? { conflictDetectionEnabled: data.conflictDetectionEnabled } : {}), + ...(data.conflictDetectionBatchSize !== undefined ? { conflictDetectionBatchSize: data.conflictDetectionBatchSize } : {}), + ...(data.conflictDetectionMinAgeDays !== undefined ? { conflictDetectionMinAgeDays: data.conflictDetectionMinAgeDays } : {}), + }, + select: { dateFormat: true, timezone: true, publishingSchedule: true, showCanvaLink: true, disabledLintRules: true, showDeletedVideos: true, conflictDetectionEnabled: true, conflictDetectionBatchSize: true, conflictDetectionMinAgeDays: true }, + }); + + // Remove open results for disabled rules and recompute lintStatus on affected videos + if (data.disabledLintRules && data.disabledLintRules.length > 0) { + // Capture affected video IDs before deleting so we can recompute their status + const affected = await this.prisma.lintResult.findMany({ + where: { + resolvedAt: null, + ruleCode: { in: data.disabledLintRules }, + video: { channel: { teamId } }, + }, + select: { videoId: true }, + distinct: ['videoId'], + }); + + await this.prisma.lintResult.deleteMany({ + where: { + resolvedAt: null, + ruleCode: { in: data.disabledLintRules }, + video: { channel: { teamId } }, + }, + }); + + // Recompute lintStatus for each affected video based on its remaining open results + await Promise.all( + affected.map(async ({ videoId }) => { + const remaining = await this.prisma.lintResult.findMany({ + where: { videoId, resolvedAt: null }, + select: { severity: true }, + }); + const status = remaining.some((r) => r.severity === 'ERROR') + ? 'ERROR' + : remaining.some((r) => r.severity === 'WARNING') + ? 'WARNING' + : 'OK'; + await this.prisma.video.update({ where: { id: videoId }, data: { lintStatus: status as any } }); + }), + ); + } + + return { + dateFormat: team.dateFormat ?? null, + timezone: team.timezone, + publishingSchedule: (team.publishingSchedule as PublishingSlot[] | null) ?? null, + showCanvaLink: team.showCanvaLink, + disabledLintRules: team.disabledLintRules, + showDeletedVideos: team.showDeletedVideos, + conflictDetectionEnabled: team.conflictDetectionEnabled, + conflictDetectionBatchSize: team.conflictDetectionBatchSize, + conflictDetectionMinAgeDays: team.conflictDetectionMinAgeDays, + }; + } + + async findNextFreeSlot(teamId: string, userId: string, channelId: string): Promise<{ slot: string | null }> { + await this.assertMember(teamId, userId); + + const team = await this.prisma.team.findUniqueOrThrow({ + where: { id: teamId }, + select: { timezone: true, publishingSchedule: true }, + }); + + const schedule = team.publishingSchedule as PublishingSlot[] | null; + if (!schedule || schedule.length === 0) return { slot: null }; + + // Verify channel belongs to this team + const channel = await this.prisma.channel.findFirst({ where: { id: channelId, teamId } }); + if (!channel) throw new ForbiddenException('Channel not found in this team'); + + // Load all future scheduled dates for this channel + const now = new Date(); + const futureVideos = await this.prisma.video.findMany({ + where: { channelId, scheduledAt: { gt: now } }, + select: { scheduledAt: true }, + }); + const takenMs = futureVideos.map((v) => v.scheduledAt!.getTime()); + + const COLLISION_MS = 30 * 60 * 1000; // ±30 minutes + const MAX_DAYS = 90; + const tz = team.timezone; + + // Walk candidate slots day by day + for (let dayOffset = 0; dayOffset <= MAX_DAYS; dayOffset++) { + const dayStart = new Date(now.getTime() + dayOffset * 24 * 60 * 60 * 1000); + + // Get the weekday (0=Sun … 6=Sat) in the team's timezone + const weekday = this.getWeekdayInZone(dayStart, tz); + + // Sort slots by time so we walk them in order within a day + const daySlots = schedule + .filter((s) => s.days.length === 0 || s.days.includes(weekday)) + .sort((a, b) => a.time.localeCompare(b.time)); + + for (const slot of daySlots) { + const candidateUtc = this.slotToUtc(dayStart, slot.time, tz); + if (candidateUtc <= now.getTime()) continue; + + const isTaken = takenMs.some((t) => Math.abs(t - candidateUtc) <= COLLISION_MS); + if (!isTaken) { + return { slot: new Date(candidateUtc).toISOString() }; + } + } + } + + return { slot: null }; + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + /** + * Returns the UTC timestamp (ms) for a given "HH:MM" slot time on the calendar + * day that contains `nearDate`, interpreted in `ianaZone`. + */ + private slotToUtc(nearDate: Date, time: string, ianaZone: string): number { + const [hh, mm] = time.split(':').map(Number); + + // Get the date components (year, month, day) in the target timezone for nearDate + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: ianaZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(nearDate); + + const year = Number(parts.find((p) => p.type === 'year')!.value); + const month = Number(parts.find((p) => p.type === 'month')!.value) - 1; + const day = Number(parts.find((p) => p.type === 'day')!.value); + + // Build a Date at midnight in UTC for that calendar date, then add hours/minutes, + // then correct for the timezone offset at that moment. + // We iterate once to converge on the correct offset (handles DST edge cases). + let candidate = Date.UTC(year, month, day, hh, mm, 0, 0); + const offset = this.getUtcOffsetMs(ianaZone, new Date(candidate)); + candidate = Date.UTC(year, month, day, hh, mm, 0, 0) - offset; + + return candidate; + } + + /** Returns the UTC offset in milliseconds for an IANA timezone at a given instant. */ + private getUtcOffsetMs(ianaZone: string, date: Date): number { + // Format the date in the target timezone and in UTC, then diff + const fmt = (tz: string) => + new Intl.DateTimeFormat('en-US', { + timeZone: tz, + year: 'numeric', month: 'numeric', day: 'numeric', + hour: 'numeric', minute: 'numeric', second: 'numeric', + hour12: false, + }).format(date); + + const local = new Date(fmt(ianaZone) + ' UTC'); + const utc = new Date(fmt('UTC') + ' UTC'); + return local.getTime() - utc.getTime(); + } + + /** Returns the weekday (0=Sun … 6=Sat) in the given IANA timezone. */ + private getWeekdayInZone(date: Date, ianaZone: string): number { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: ianaZone, + weekday: 'short', + }).formatToParts(date); + const name = parts.find((p) => p.type === 'weekday')!.value; + return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(name); + } + + private validateSchedule(slots: PublishingSlot[]) { + for (const slot of slots) { + if (!/^\d{2}:\d{2}$/.test(slot.time)) { + throw new BadRequestException(`Invalid slot time "${slot.time}" — expected HH:MM`); + } + for (const d of slot.days) { + if (d < 0 || d > 6) throw new BadRequestException(`Invalid day ${d} — must be 0–6`); + } + } + } + + private validateTimezone(tz: string) { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }); + } catch { + throw new BadRequestException(`Invalid IANA timezone "${tz}"`); + } + } + + private async assertMember(teamId: string, userId: string) { + const m = await this.prisma.teamMember.findUnique({ + where: { userId_teamId: { userId, teamId } }, + }); + if (!m) throw new ForbiddenException('Not a member of this team'); + return m; + } + + private async assertRole(teamId: string, userId: string, minRole: TeamRole) { + const PRIORITY: Record = { + [TeamRole.OWNER]: 5, [TeamRole.ADMIN]: 4, [TeamRole.EDITOR]: 3, + [TeamRole.REVIEWER]: 2, [TeamRole.READONLY]: 1, + }; + const m = await this.assertMember(teamId, userId); + if ((PRIORITY[m.role] ?? 0) < PRIORITY[minRole]) { + throw new ForbiddenException('Insufficient role'); + } + return m; + } +} diff --git a/backend/src/modules/templates/dto/create-template.dto.ts b/backend/src/modules/templates/dto/create-template.dto.ts new file mode 100644 index 0000000..015fc24 --- /dev/null +++ b/backend/src/modules/templates/dto/create-template.dto.ts @@ -0,0 +1,12 @@ +import { IsString, IsOptional, IsArray, IsObject } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateTemplateDto { + @ApiProperty() @IsString() name: string; + @ApiPropertyOptional() @IsOptional() @IsString() description?: string; + @ApiProperty() @IsArray() defaultBlocks: string[]; + @ApiPropertyOptional() @IsOptional() @IsObject() defaultOverrides?: Record; + @ApiProperty() @IsObject() rules: Record; + @ApiProperty() @IsObject() variables: Record; + @ApiPropertyOptional() @IsOptional() @IsObject() videoFields?: Record; +} diff --git a/backend/src/modules/templates/dto/update-template.dto.ts b/backend/src/modules/templates/dto/update-template.dto.ts new file mode 100644 index 0000000..b30e734 --- /dev/null +++ b/backend/src/modules/templates/dto/update-template.dto.ts @@ -0,0 +1,13 @@ +import { IsString, IsOptional, IsArray, IsObject, IsBoolean } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateTemplateDto { + @ApiPropertyOptional() @IsOptional() @IsString() name?: string; + @ApiPropertyOptional() @IsOptional() @IsString() description?: string; + @ApiPropertyOptional() @IsOptional() @IsArray() defaultBlocks?: string[]; + @ApiPropertyOptional() @IsOptional() @IsObject() defaultOverrides?: Record; + @ApiPropertyOptional() @IsOptional() @IsObject() rules?: Record; + @ApiPropertyOptional() @IsOptional() @IsObject() variables?: Record; + @ApiPropertyOptional() @IsOptional() @IsObject() videoFields?: Record; + @ApiPropertyOptional() @IsOptional() @IsBoolean() active?: boolean; +} diff --git a/backend/src/modules/templates/templates.controller.ts b/backend/src/modules/templates/templates.controller.ts new file mode 100644 index 0000000..2e3de52 --- /dev/null +++ b/backend/src/modules/templates/templates.controller.ts @@ -0,0 +1,50 @@ +import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { TeamRole } from '@prisma/client'; +import { TemplatesService, ApplyOptions } from './templates.service'; +import { CreateTemplateDto } from './dto/create-template.dto'; +import { UpdateTemplateDto } from './dto/update-template.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; + +@ApiTags('templates') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('templates') +export class TemplatesController { + constructor(private readonly service: TemplatesService) {} + + @Get() + findAll(@Req() req: any) { return this.service.findAll(req.user.teamId); } + + @Get(':id') + findById(@Param('id') id: string, @Req() req: any) { return this.service.findById(id, req.user.teamId); } + + @Post() @Roles(TeamRole.EDITOR) + create(@Body() dto: CreateTemplateDto, @Req() req: any) { return this.service.create(dto, req.user.id, req.user.teamId); } + + @Patch(':id') @Roles(TeamRole.EDITOR) + update(@Param('id') id: string, @Body() dto: UpdateTemplateDto, @Req() req: any) { return this.service.update(id, dto, req.user.id, req.user.teamId); } + + @Get(':id/usage') @ApiOperation({ summary: 'Videos using this template' }) + usage(@Param('id') id: string, @Req() req: any) { return this.service.getUsage(id, req.user.teamId); } + + @Delete(':id') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Delete template (only if not in use)' }) + remove(@Param('id') id: string, @Req() req: any) { return this.service.delete(id, req.user.teamId, req.user.id); } + + @Post(':id/apply/:videoId') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Apply template to a video' }) + applyToVideo( + @Param('id') id: string, + @Param('videoId') videoId: string, + @Body() body: ApplyOptions, + @Req() req: any, + ) { + return this.service.applyToVideo(id, videoId, req.user.teamId, req.user.id, body); + } + + @Post(':id/render-preview') @ApiOperation({ summary: 'Render template with sample data' }) + renderPreview(@Param('id') id: string, @Body() body: { variables?: Record }, @Req() req: any) { + return this.service.renderPreview(id, body.variables ?? {}, req.user.teamId); + } +} diff --git a/backend/src/modules/templates/templates.module.ts b/backend/src/modules/templates/templates.module.ts new file mode 100644 index 0000000..c61967a --- /dev/null +++ b/backend/src/modules/templates/templates.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TemplatesService } from './templates.service'; +import { TemplatesController } from './templates.controller'; +import { RenderEngineModule } from '../../shared/render-engine/render-engine.module'; +import { AuditModule } from '../../shared/audit/audit.module'; + +@Module({ + imports: [RenderEngineModule, AuditModule], + providers: [TemplatesService], + controllers: [TemplatesController], + exports: [TemplatesService], +}) +export class TemplatesModule {} diff --git a/backend/src/modules/templates/templates.service.ts b/backend/src/modules/templates/templates.service.ts new file mode 100644 index 0000000..c41abf7 --- /dev/null +++ b/backend/src/modules/templates/templates.service.ts @@ -0,0 +1,164 @@ +import { Injectable, NotFoundException, ForbiddenException, ConflictException } from '@nestjs/common'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { RenderEngineService } from '../../shared/render-engine/render-engine.service'; +import { AuditService } from '../../shared/audit/audit.service'; +import { CreateTemplateDto } from './dto/create-template.dto'; +import { UpdateTemplateDto } from './dto/update-template.dto'; + +export interface ApplyOptions { + applyVideoFields: boolean; + applyDescriptionConfig: boolean; +} + +@Injectable() +export class TemplatesService { + constructor( + private readonly prisma: PrismaService, + private readonly renderEngine: RenderEngineService, + private readonly audit: AuditService, + ) {} + + findAll(teamId: string) { + return this.prisma.template.findMany({ where: { active: true, teamId }, orderBy: { name: 'asc' } }); + } + + async findById(id: string, teamId: string) { + const template = await this.prisma.template.findFirst({ where: { id, teamId } }); + if (!template) throw new NotFoundException(`Template ${id} not found`); + return template; + } + + async create(dto: CreateTemplateDto, actorId: string, teamId: string) { + const template = await this.prisma.template.create({ data: { ...dto, teamId } }); + await this.audit.log(actorId, 'Template', template.id, 'create', null, template); + return template; + } + + async update(id: string, dto: UpdateTemplateDto, actorId: string, teamId: string) { + const before = await this.prisma.template.findFirst({ where: { id, teamId } }); + if (!before) throw new NotFoundException(`Template ${id} not found`); + + await this.prisma.templateVersion.create({ + data: { templateId: id, version: before.version, snapshot: before as any }, + }); + + const updated = await this.prisma.template.update({ + where: { id }, + data: { ...dto, version: { increment: 1 } }, + }); + + await this.audit.log(actorId, 'Template', id, 'update', before, updated); + return updated; + } + + async applyToVideo( + templateId: string, + videoId: string, + teamId: string, + actorId: string, + options: ApplyOptions, + ) { + const [template, video] = await Promise.all([ + this.prisma.template.findFirst({ where: { id: templateId, teamId } }), + this.prisma.video.findFirst({ where: { id: videoId, channel: { teamId } } }), + ]); + if (!template) throw new NotFoundException(`Template ${templateId} not found`); + if (!video) throw new NotFoundException(`Video ${videoId} not found`); + + const appliedFields: string[] = []; + + const templateAny = template as any; + if (options.applyVideoFields && templateAny.videoFields) { + const fields = templateAny.videoFields as Record; + const updateData: Record = {}; + + const ALLOWED_FIELDS = [ + 'tags', 'privacyStatus', 'categoryId', 'defaultLanguage', + 'defaultAudioLanguage', 'selfDeclaredMadeForKids', 'embeddable', 'license', 'gameTitle', + ]; + for (const key of ALLOWED_FIELDS) { + if (fields[key] !== undefined) { + updateData[key] = fields[key]; + appliedFields.push(key); + } + } + + if (Object.keys(updateData).length > 0) { + updateData.templateId = templateId; + await this.prisma.video.update({ where: { id: videoId }, data: updateData }); + } + } + + if (options.applyDescriptionConfig) { + const blockOrder = (template.defaultBlocks as string[]) ?? []; + const variableValues = (template.variables as Record) ?? {}; + const blockOverrides = ((template as any).defaultOverrides as Record) ?? {}; + + const existing = await this.prisma.videoConfig.findUnique({ where: { videoId } }); + const configData = { + templateId, + blockOrder, + blockOverrides, + variableValues, + version: existing ? existing.version + 1 : 1, + }; + + if (existing) { + await this.prisma.videoConfig.update({ where: { videoId }, data: configData }); + } else { + await this.prisma.videoConfig.create({ data: { videoId, ...configData } }); + } + + // Ensure templateId is set on the video even if no video fields were applied + if (!options.applyVideoFields) { + await this.prisma.video.update({ where: { id: videoId }, data: { templateId } }); + } + + appliedFields.push('descriptionConfig'); + } + + await this.audit.log(actorId, 'Template', templateId, 'apply', { videoId }, { appliedFields }); + return { appliedFields }; + } + + async getUsage(id: string, teamId: string) { + const configs = await this.prisma.videoConfig.findMany({ + where: { templateId: id, video: { channel: { teamId } } }, + select: { videoId: true, video: { select: { title: true } } }, + }); + return { videos: configs.map((c) => ({ id: c.videoId, title: c.video.title })) }; + } + + async delete(id: string, teamId: string, actorId: string) { + const template = await this.prisma.template.findFirst({ where: { id, teamId } }); + if (!template) throw new NotFoundException(`Template ${id} not found`); + const videoCount = await this.prisma.videoConfig.count({ where: { templateId: id } }); + if (videoCount > 0) throw new ConflictException(`Template is used by ${videoCount} video(s) and cannot be deleted`); + await this.prisma.templateVersion.deleteMany({ where: { templateId: id } }); + await this.prisma.template.delete({ where: { id } }); + await this.audit.log(actorId, 'Template', id, 'delete', template, null); + return { deleted: true }; + } + + async renderPreview(id: string, sampleVariables: Record, teamId: string) { + const template = await this.prisma.template.findFirst({ where: { id, teamId } }); + if (!template) throw new NotFoundException(`Template ${id} not found`); + const blockOrder = template.defaultBlocks as string[]; + const realBlockIds = blockOrder.filter((bid) => !bid.startsWith('freetext:')); + const blocks = await this.prisma.descriptionBlock.findMany({ where: { id: { in: realBlockIds }, teamId } }); + const defaultOverrides = ((template as any).defaultOverrides as Record) ?? {}; + + return this.renderEngine.render({ + video: { title: 'Preview', tags: [] }, + config: { + blockOrder, + blockOverrides: defaultOverrides, + variableValues: { ...(template.variables as Record), ...sampleVariables }, + collaboratorIds: [], + }, + blocks, + collaborators: [], + activeCampaignBlocks: [], + }); + } +} diff --git a/backend/src/modules/video-configs/dto/upsert-video-config.dto.ts b/backend/src/modules/video-configs/dto/upsert-video-config.dto.ts new file mode 100644 index 0000000..82ea295 --- /dev/null +++ b/backend/src/modules/video-configs/dto/upsert-video-config.dto.ts @@ -0,0 +1,11 @@ +import { IsArray, IsObject, IsOptional, IsString, IsBoolean } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpsertVideoConfigDto { + @ApiPropertyOptional() @IsOptional() @IsString() templateId?: string; + @ApiProperty({ type: [String] }) @IsArray() blockOrder: string[]; + @ApiProperty() @IsObject() blockOverrides: Record; + @ApiProperty() @IsObject() variableValues: Record; + @ApiProperty({ type: [String] }) @IsArray() @IsOptional() collaboratorIds?: string[]; + @ApiPropertyOptional() @IsOptional() @IsBoolean() autoRender?: boolean; +} diff --git a/backend/src/modules/video-configs/video-configs.controller.ts b/backend/src/modules/video-configs/video-configs.controller.ts new file mode 100644 index 0000000..7db69c6 --- /dev/null +++ b/backend/src/modules/video-configs/video-configs.controller.ts @@ -0,0 +1,29 @@ +import { Controller, Get, Put, Post, Param, Body, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { TeamRole } from '@prisma/client'; +import { VideoConfigsService } from './video-configs.service'; +import { UpsertVideoConfigDto } from './dto/upsert-video-config.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; + +@ApiTags('video-configs') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('video-configs') +export class VideoConfigsController { + constructor(private readonly service: VideoConfigsService) {} + + @Get(':videoId') @ApiOperation({ summary: 'Get config for a video' }) + findOne(@Param('videoId') videoId: string) { return this.service.findByVideoId(videoId); } + + @Put(':videoId') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Save/update video config' }) + upsert(@Param('videoId') videoId: string, @Body() dto: UpsertVideoConfigDto, @Req() req: any) { + return this.service.upsert(videoId, dto, req.user.id); + } + + @Post(':videoId/render-preview') @ApiOperation({ summary: 'Preview render without saving' }) + renderPreview(@Param('videoId') videoId: string, @Body() dto: UpsertVideoConfigDto) { + return this.service.renderPreview(videoId, dto); + } +} diff --git a/backend/src/modules/video-configs/video-configs.module.ts b/backend/src/modules/video-configs/video-configs.module.ts new file mode 100644 index 0000000..d539e05 --- /dev/null +++ b/backend/src/modules/video-configs/video-configs.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { VideoConfigsService } from './video-configs.service'; +import { VideoConfigsController } from './video-configs.controller'; +import { RenderEngineModule } from '../../shared/render-engine/render-engine.module'; +import { AuditModule } from '../../shared/audit/audit.module'; +import { TeamVariablesModule } from '../team-variables/team-variables.module'; +import { QUEUES } from '../../queues/queues.constants'; + +@Module({ + imports: [ + RenderEngineModule, + AuditModule, + TeamVariablesModule, + BullModule.registerQueue({ name: QUEUES.RENDER }), + ], + providers: [VideoConfigsService], + controllers: [VideoConfigsController], +}) +export class VideoConfigsModule {} diff --git a/backend/src/modules/video-configs/video-configs.service.ts b/backend/src/modules/video-configs/video-configs.service.ts new file mode 100644 index 0000000..5741a24 --- /dev/null +++ b/backend/src/modules/video-configs/video-configs.service.ts @@ -0,0 +1,99 @@ +import { Injectable } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { RenderEngineService } from '../../shared/render-engine/render-engine.service'; +import { AuditService } from '../../shared/audit/audit.service'; +import { TeamVariablesService } from '../team-variables/team-variables.service'; +import { QUEUES } from '../../queues/queues.constants'; +import { UpsertVideoConfigDto } from './dto/upsert-video-config.dto'; + +@Injectable() +export class VideoConfigsService { + constructor( + private readonly prisma: PrismaService, + private readonly renderEngine: RenderEngineService, + private readonly audit: AuditService, + private readonly teamVars: TeamVariablesService, + @InjectQueue(QUEUES.RENDER) private readonly renderQueue: Queue, + ) {} + + async findByVideoId(videoId: string) { + return this.prisma.videoConfig.findUnique({ where: { videoId } }); + } + + async upsert(videoId: string, dto: UpsertVideoConfigDto, actorId: string) { + const existing = await this.prisma.videoConfig.findUnique({ where: { videoId } }); + + const data = { + templateId: dto.templateId, + blockOrder: dto.blockOrder, + blockOverrides: dto.blockOverrides, + variableValues: dto.variableValues, + version: existing ? existing.version + 1 : 1, + }; + + const config = existing + ? await this.prisma.videoConfig.update({ where: { videoId }, data }) + : await this.prisma.videoConfig.create({ data: { videoId, ...data } }); + + await this.audit.log(actorId, 'VideoConfig', videoId, existing ? 'update' : 'create', existing, config); + + if (dto.autoRender) { + await this.renderQueue.add('render', { videoId }); + } + + return config; + } + + async renderPreview(videoId: string, dto: UpsertVideoConfigDto) { + const video = await this.prisma.video.findUniqueOrThrow({ + where: { id: videoId }, + include: { + channel: { select: { teamId: true } }, + playlists: { include: { playlist: { select: { title: true, youtubePlaylistId: true } } } }, + }, + }); + + const teamId = video.channel.teamId; + const now = new Date(); + const [blocks, teamVariables, dateFormat, collaborators, activeCampaignBlocks] = await Promise.all([ + this.prisma.descriptionBlock.findMany({ where: { id: { in: dto.blockOrder } } }), + this.teamVars.getMap(teamId), + this.teamVars.getDateFormat(teamId), + this.prisma.collaborator.findMany({ where: { id: { in: dto.collaboratorIds } } }), + this.prisma.descriptionBlock.findMany({ + where: { + teamId, + type: 'CAMPAIGN', + active: true, + campaign: { startAt: { lte: now }, OR: [{ endAt: null }, { endAt: { gte: now } }] }, + }, + }), + ]); + + return this.renderEngine.render({ + video: { + title: video.title, + tags: video.tags, + categoryId: video.categoryId, + scheduledAt: video.scheduledAt, + recordingDate: video.recordingDate, + gameTitle: video.gameTitle, + language: video.defaultLanguage, + playlists: video.playlists.map((vp) => vp.playlist), + }, + config: { + blockOrder: dto.blockOrder, + blockOverrides: dto.blockOverrides, + variableValues: dto.variableValues, + teamVariables, + dateFormat, + collaboratorIds: dto.collaboratorIds ?? [], + }, + blocks, + collaborators, + activeCampaignBlocks, + }); + } +} diff --git a/backend/src/modules/videos/dto/bulk-preview.dto.ts b/backend/src/modules/videos/dto/bulk-preview.dto.ts new file mode 100644 index 0000000..1314e2c --- /dev/null +++ b/backend/src/modules/videos/dto/bulk-preview.dto.ts @@ -0,0 +1,9 @@ +import { IsString, IsArray, IsOptional, IsObject } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class BulkPreviewDto { + @ApiProperty() @IsString() type: string; + @ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) videoIds?: string[]; + @ApiPropertyOptional() @IsOptional() @IsString() savedViewId?: string; + @ApiProperty() @IsObject() payload: Record; +} diff --git a/backend/src/modules/videos/dto/query-videos.dto.ts b/backend/src/modules/videos/dto/query-videos.dto.ts new file mode 100644 index 0000000..9bc4619 --- /dev/null +++ b/backend/src/modules/videos/dto/query-videos.dto.ts @@ -0,0 +1,43 @@ +import { IsOptional, IsString, IsEnum, IsInt, Min, IsBoolean } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { LintStatus, PrivacyStatus } from '@prisma/client'; +import { Type, Transform } from 'class-transformer'; + +export class QueryVideosDto { + @ApiPropertyOptional() @IsOptional() @IsString() search?: string; + @ApiPropertyOptional({ enum: LintStatus }) @IsOptional() @IsEnum(LintStatus) lintStatus?: LintStatus; + @ApiPropertyOptional({ enum: PrivacyStatus }) @IsOptional() @IsEnum(PrivacyStatus) privacyStatus?: PrivacyStatus; + @ApiPropertyOptional() @IsOptional() @IsString() channelId?: string; + @ApiPropertyOptional() @IsOptional() @IsString() templateId?: string; + @ApiPropertyOptional() @IsOptional() @IsString() collaboratorId?: string; + @ApiPropertyOptional() @IsOptional() @IsString() from?: string; + @ApiPropertyOptional() @IsOptional() @IsString() to?: string; + /** Filter to videos that are scheduled (PRIVATE status with a future scheduledAt) */ + @ApiPropertyOptional() @IsOptional() @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() scheduled?: boolean; + /** Exclude videos that have a future scheduledAt (used by the Private tab to hide scheduled videos) */ + @ApiPropertyOptional() @IsOptional() @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() notScheduled?: boolean; + /** Filter to videos with any lint issue (ERROR or WARNING) */ + @ApiPropertyOptional() @IsOptional() @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() hasLintIssues?: boolean; + /** Filter to videos with a remote conflict */ + @ApiPropertyOptional() @IsOptional() @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() remoteConflict?: boolean; + /** Filter to videos that have pending changes (not yet pushed to YouTube) */ + @ApiPropertyOptional() @IsOptional() @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() pendingSync?: boolean; + /** Show only videos soft-deleted on YouTube (youtubeDeletedAt is not null); if false/absent, deleted videos are excluded */ + @ApiPropertyOptional() @IsOptional() @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() deletedOnYouTube?: boolean; + /** Filter by tag (partial match — video must have at least one tag containing this string) */ + @ApiPropertyOptional() @IsOptional() @IsString() tagsSearch?: string; + /** Filter by YouTube category ID */ + @ApiPropertyOptional() @IsOptional() @IsString() categoryId?: string; + /** Filter by default language (BCP-47 code) */ + @ApiPropertyOptional() @IsOptional() @IsString() defaultLanguage?: string; + /** Filter to embeddable or non-embeddable videos */ + @ApiPropertyOptional() @IsOptional() @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() embeddable?: boolean; + /** Filter by license ('youtube' or 'creativeCommon') */ + @ApiPropertyOptional() @IsOptional() @IsString() license?: string; + /** Filter to videos marked/not marked as made for kids */ + @ApiPropertyOptional() @IsOptional() @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() selfDeclaredMadeForKids?: boolean; + @ApiPropertyOptional({ default: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number = 1; + @ApiPropertyOptional({ default: 50 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) limit?: number = 50; + @ApiPropertyOptional() @IsOptional() @IsString() sort?: string; + @ApiPropertyOptional({ enum: ['asc', 'desc'] }) @IsOptional() @IsEnum(['asc', 'desc']) order?: 'asc' | 'desc' = 'desc'; +} diff --git a/backend/src/modules/videos/dto/update-video.dto.ts b/backend/src/modules/videos/dto/update-video.dto.ts new file mode 100644 index 0000000..b167fe1 --- /dev/null +++ b/backend/src/modules/videos/dto/update-video.dto.ts @@ -0,0 +1,22 @@ +import { IsOptional, IsString, IsArray, IsEnum, IsDateString, IsBoolean } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { PrivacyStatus } from '@prisma/client'; + +export class UpdateVideoDto { + @ApiPropertyOptional() @IsOptional() @IsString() title?: string; + @ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[]; + @ApiPropertyOptional({ enum: PrivacyStatus }) @IsOptional() @IsEnum(PrivacyStatus) privacyStatus?: PrivacyStatus; + @ApiPropertyOptional() @IsOptional() @IsDateString() scheduledAt?: string; + @ApiPropertyOptional() @IsOptional() @IsString() categoryId?: string; + @ApiPropertyOptional() @IsOptional() @IsString() templateId?: string; + + // Extended metadata (madeForKids, ageRestricted, containsPaidPromotion are read-only from YouTube) + @ApiPropertyOptional() @IsOptional() @IsBoolean() selfDeclaredMadeForKids?: boolean; + @ApiPropertyOptional() @IsOptional() @IsBoolean() embeddable?: boolean; + @ApiPropertyOptional() @IsOptional() @IsString() license?: string; + @ApiPropertyOptional() @IsOptional() @IsString() defaultLanguage?: string; + @ApiPropertyOptional() @IsOptional() @IsString() defaultAudioLanguage?: string; + @ApiPropertyOptional() @IsOptional() @IsDateString() recordingDate?: string; + @ApiPropertyOptional() @IsOptional() @IsString() gameTitle?: string; + @ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) collaboratorIds?: string[]; +} diff --git a/backend/src/modules/videos/videos.controller.ts b/backend/src/modules/videos/videos.controller.ts new file mode 100644 index 0000000..4c29353 --- /dev/null +++ b/backend/src/modules/videos/videos.controller.ts @@ -0,0 +1,97 @@ +import { Controller, Get, Patch, Post, Param, Body, Query, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { TeamRole } from '@prisma/client'; +import { VideosService } from './videos.service'; +import { QueryVideosDto } from './dto/query-videos.dto'; +import { UpdateVideoDto } from './dto/update-video.dto'; +import { BulkPreviewDto } from './dto/bulk-preview.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; + +@ApiTags('videos') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('videos') +export class VideosController { + constructor(private readonly service: VideosService) {} + + @Get('diagnose') + @ApiOperation({ summary: 'Diagnose video count discrepancy' }) + diagnose(@Req() req: any) { + return this.service.diagnoseCount(req.user.teamId); + } + + @Post('reassign/:channelId') + @ApiOperation({ summary: 'Move videos misassigned to another channel into the correct channel' }) + @Roles(TeamRole.ADMIN) + reassign(@Param('channelId') channelId: string, @Req() req: any) { + return this.service.reassignOrphanedVideos(req.user.teamId, channelId); + } + + @Get('stats/overview') + @ApiOperation({ summary: 'Aggregated stats for the overview dashboard' }) + overviewStats(@Req() req: any) { + return this.service.getOverviewStats(req.user.teamId); + } + + @Get() + @ApiOperation({ summary: 'List videos with filters' }) + findAll(@Query() query: QueryVideosDto, @Req() req: any) { + return this.service.findAll(query, req.user.teamId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get single video' }) + findOne(@Param('id') id: string, @Req() req: any) { + return this.service.findOne(id, req.user.teamId); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update video fields' }) + @Roles(TeamRole.EDITOR) + update(@Param('id') id: string, @Body() dto: UpdateVideoDto, @Req() req: any) { + return this.service.update(id, dto, req.user.id, req.user.teamId); + } + + @Post('bulk-preview') + @ApiOperation({ summary: 'Dry-run for bulk action' }) + @Roles(TeamRole.EDITOR) + bulkPreview(@Body() dto: BulkPreviewDto, @Req() req: any) { + return this.service.bulkPreview(dto, req.user.id, req.user.teamId); + } + + @Post('bulk-apply') + @ApiOperation({ summary: 'Apply bulk action' }) + @Roles(TeamRole.EDITOR) + bulkApply(@Body() dto: BulkPreviewDto, @Req() req: any) { + return this.service.bulkApply(dto, req.user.id, req.user.teamId); + } + + @Post(':id/render') + @ApiOperation({ summary: 'Render video description' }) + @Roles(TeamRole.EDITOR) + render(@Param('id') id: string, @Req() req: any) { + return this.service.renderDescription(id, req.user.teamId); + } + + @Post(':id/refresh') + @ApiOperation({ summary: 'Pull latest metadata from YouTube into our DB' }) + refresh(@Param('id') id: string, @Req() req: any) { + return this.service.refreshFromYouTube(id, req.user.teamId); + } + + @Post(':id/sync') + @ApiOperation({ summary: 'Enqueue YouTube sync' }) + @Roles(TeamRole.EDITOR) + sync(@Param('id') id: string) { + return this.service.enqueueSyncJob(id); + } + + @Post(':id/accept-remote') + @ApiOperation({ summary: 'Adopt the pending remote snapshot as the new local state (no YouTube call)' }) + @Roles(TeamRole.EDITOR) + acceptRemote(@Param('id') id: string, @Req() req: any) { + return this.service.acceptRemote(id, req.user.id, req.user.teamId); + } +} diff --git a/backend/src/modules/videos/videos.module.ts b/backend/src/modules/videos/videos.module.ts new file mode 100644 index 0000000..122de7e --- /dev/null +++ b/backend/src/modules/videos/videos.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { VideosService } from './videos.service'; +import { VideosController } from './videos.controller'; +import { RenderEngineModule } from '../../shared/render-engine/render-engine.module'; +import { AuditModule } from '../../shared/audit/audit.module'; +import { YouTubeSyncModule } from '../youtube-sync/youtube-sync.module'; +import { QUEUES } from '../../queues/queues.constants'; + +@Module({ + imports: [ + RenderEngineModule, + AuditModule, + YouTubeSyncModule, + BullModule.registerQueue({ name: QUEUES.YOUTUBE_SYNC }, { name: QUEUES.RENDER }), + ], + providers: [VideosService], + controllers: [VideosController], + exports: [VideosService], +}) +export class VideosModule {} diff --git a/backend/src/modules/videos/videos.service.ts b/backend/src/modules/videos/videos.service.ts new file mode 100644 index 0000000..199666f --- /dev/null +++ b/backend/src/modules/videos/videos.service.ts @@ -0,0 +1,522 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { randomUUID } from 'crypto'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { VideoRenderService } from '../../shared/render-engine/video-render.service'; +import { AuditService } from '../../shared/audit/audit.service'; +import { YouTubeApiClient } from '../youtube-sync/youtube-api.client'; +import { hashMetadata } from '../../shared/render-engine/hash'; +import { computePendingChanges } from '../../shared/render-engine/pending-changes'; +import { QUEUES } from '../../queues/queues.constants'; +import { QueryVideosDto } from './dto/query-videos.dto'; +import { UpdateVideoDto } from './dto/update-video.dto'; +import { BulkPreviewDto } from './dto/bulk-preview.dto'; +import { Prisma, PrivacyStatus } from '@prisma/client'; + +@Injectable() +export class VideosService { + constructor( + private readonly prisma: PrismaService, + private readonly videoRender: VideoRenderService, + private readonly audit: AuditService, + private readonly ytApi: YouTubeApiClient, + @InjectQueue(QUEUES.YOUTUBE_SYNC) private readonly syncQueue: Queue, + @InjectQueue(QUEUES.RENDER) private readonly renderQueue: Queue, + ) {} + + async reassignOrphanedVideos(teamId: string, targetChannelId: string) { + const channel = await this.prisma.channel.findFirstOrThrow({ + where: { id: targetChannelId, teamId }, + }); + + const playlistId = + channel.uploadsPlaylistId ?? (await this.ytApi.getUploadsPlaylistId(targetChannelId)); + + const { ids: youtubeIds } = await this.ytApi.listPlaylistVideoIds(targetChannelId, playlistId); + + // Find which of those YouTube IDs are in the DB but NOT under this channel + const misassigned = await this.prisma.video.findMany({ + where: { + youtubeVideoId: { in: youtubeIds }, + NOT: { channelId: targetChannelId }, + }, + select: { id: true, youtubeVideoId: true, channelId: true }, + }); + + if (misassigned.length > 0) { + await this.prisma.video.updateMany({ + where: { id: { in: misassigned.map((v) => v.id) } }, + data: { channelId: targetChannelId }, + }); + } + + return { reassigned: misassigned.length, total: youtubeIds.length }; + } + + async diagnoseCount(teamId: string) { + const totalInDb = await this.prisma.video.count(); + const visibleToTeam = await this.prisma.video.count({ where: { channel: { teamId } } }); + const hidden = await this.prisma.$queryRaw<{ youtubeVideoId: string; channelId: string; channelTeamId: string | null }[]>` + SELECT v."youtubeVideoId", v."channelId", c."teamId" AS "channelTeamId" + FROM "Video" v + LEFT JOIN "Channel" c ON c.id = v."channelId" + WHERE c."teamId" IS NULL OR c."teamId" != ${teamId} + LIMIT 20 + `; + return { totalInDb, visibleToTeam, hidden }; + } + + async findAll(query: QueryVideosDto, teamId: string) { + const { + search, lintStatus, hasLintIssues, privacyStatus, channelId, templateId, collaboratorId, + from, to, scheduled, notScheduled, remoteConflict, pendingSync, deletedOnYouTube, + tagsSearch, categoryId, defaultLanguage, embeddable, license, selfDeclaredMadeForKids, + page = 1, limit = 50, sort = 'createdAt', order = 'desc', + } = query; + + const where: Prisma.VideoWhereInput = { + channel: { teamId }, + youtubeDeletedAt: deletedOnYouTube ? { not: null } : null, + }; + + if (search) { + where.OR = [ + { title: { contains: search, mode: 'insensitive' } }, + { tags: { has: search } }, + ]; + } + if (lintStatus) where.lintStatus = lintStatus as any; + if (hasLintIssues) where.lintStatus = { in: ['ERROR', 'WARNING'] } as any; + if (privacyStatus) where.privacyStatus = privacyStatus as any; + if (channelId) where.channelId = channelId; + if (templateId) where.templateId = templateId; + if (collaboratorId) (where as any).collaboratorIds = { array_contains: collaboratorId }; + if (from || to) { + where.publishedAt = {}; + if (from) (where.publishedAt as any).gte = new Date(from); + if (to) (where.publishedAt as any).lte = new Date(to); + } + if (scheduled) { + where.privacyStatus = 'PRIVATE' as any; + where.scheduledAt = { gt: new Date() }; + } + if (notScheduled) { + // Exclude videos with a future scheduledAt (null or past = truly private, not scheduled) + const noFutureSlot = { OR: [{ scheduledAt: null }, { scheduledAt: { lte: new Date() } }] }; + where.AND = [...(Array.isArray(where.AND) ? where.AND : []), noFutureSlot] as any; + } + if (remoteConflict) where.remoteConflict = true; + if (tagsSearch) where.tags = { has: tagsSearch }; + if (categoryId) where.categoryId = categoryId; + if (defaultLanguage) where.defaultLanguage = defaultLanguage; + if (embeddable !== undefined) where.embeddable = embeddable; + if (license) (where as any).license = license; + if (selfDeclaredMadeForKids !== undefined) where.selfDeclaredMadeForKids = selfDeclaredMadeForKids; + + if (pendingSync) { + const allVideos = await this.prisma.video.findMany({ + where: { channel: { teamId } }, + select: { + id: true, title: true, tags: true, lastSyncedHash: true, + renderedDescription: true, youtubeDescription: true, + privacyStatus: true, categoryId: true, defaultLanguage: true, defaultAudioLanguage: true, + selfDeclaredMadeForKids: true, embeddable: true, license: true, recordingDate: true, + } as any, + }); + const pendingIds: string[] = []; + for (const v of allVideos as any[]) { + if (this.computePendingChanges(v)) pendingIds.push(v.id); + } + where.id = { in: pendingIds }; + } + + if (sort === 'publishedAt') { + // Prisma orderBy cannot express CASE WHEN scheduledAt > NOW() THEN scheduledAt ELSE ... + // A compound sort groups ALL non-null scheduledAt rows together, putting past-scheduled- + // now-published videos above recently published videos. Fix: fetch all matching IDs with + // just the date fields, sort in JS using the correct COALESCE-equivalent, then paginate. + const now = new Date(); + const allDates = await this.prisma.video.findMany({ + where, + select: { id: true, scheduledAt: true, publishedAt: true, createdAt: true }, + }); + + allDates.sort((a, b) => { + const aDate = (a.scheduledAt && a.scheduledAt > now ? a.scheduledAt : null) ?? a.publishedAt ?? a.createdAt; + const bDate = (b.scheduledAt && b.scheduledAt > now ? b.scheduledAt : null) ?? b.publishedAt ?? b.createdAt; + return order === 'desc' ? bDate.getTime() - aDate.getTime() : aDate.getTime() - bDate.getTime(); + }); + + const total = allDates.length; + const pageIds = allDates.slice((page - 1) * limit, page * limit).map((r) => r.id); + + const pageItems = await this.prisma.video.findMany({ + where: { id: { in: pageIds } }, + include: { + template: { select: { id: true, name: true } }, + lintResults: { where: { resolvedAt: null }, select: { severity: true, ruleCode: true } }, + playlists: { include: { playlist: { select: { id: true, title: true } } } }, + }, + }); + + const idToItem = new Map(pageItems.map((v) => [v.id, v])); + const enriched = pageIds.map((id) => { + const v = idToItem.get(id)!; + return { ...v, hasPendingChanges: this.computePendingChanges(v as any) }; + }); + + return { total, page, limit, items: enriched }; + } + + const orderBy: Prisma.VideoOrderByWithRelationInput = { [sort]: order }; + + const [total, items] = await Promise.all([ + this.prisma.video.count({ where }), + this.prisma.video.findMany({ + where, + include: { + template: { select: { id: true, name: true } }, + lintResults: { where: { resolvedAt: null }, select: { severity: true, ruleCode: true } }, + playlists: { include: { playlist: { select: { id: true, title: true } } } }, + }, + orderBy, + skip: (page - 1) * limit, + take: limit, + }), + ]); + + const enriched = items.map((v) => { + return { ...v, hasPendingChanges: this.computePendingChanges(v as any) }; + }); + + return { total, page, limit, items: enriched }; + } + + async getOverviewStats(teamId: string) { + const now = new Date(); + + const allVideos = await this.prisma.video.findMany({ + where: { channel: { teamId }, youtubeDeletedAt: null }, + select: { + id: true, title: true, + tags: true, lastSyncedHash: true, lastSyncedAt: true, + renderedDescription: true, youtubeDescription: true, + privacyStatus: true, categoryId: true, defaultLanguage: true, defaultAudioLanguage: true, + selfDeclaredMadeForKids: true, embeddable: true, license: true, recordingDate: true, + } as any, + }); + + const pendingItems: { id: string; title: string }[] = []; + for (const v of allVideos as any[]) { + const shared = { + tags: v.tags as string[], + categoryId: v.categoryId ?? null, + privacyStatus: v.privacyStatus ?? null, + defaultLanguage: v.defaultLanguage ?? null, + defaultAudioLanguage: v.defaultAudioLanguage ?? null, + selfDeclaredMadeForKids: v.selfDeclaredMadeForKids ?? false, + embeddable: v.embeddable ?? true, + license: v.license ?? 'youtube', + recordingDate: v.recordingDate ?? null, + }; + let pending: boolean; + if (v.lastSyncedHash !== null) { + pending = v.lastSyncedHash !== hashMetadata({ title: v.title, description: v.renderedDescription ?? v.youtubeDescription ?? '', ...shared }); + } else { + const baseline = hashMetadata({ title: v.title, description: v.youtubeDescription ?? '', ...shared, recordingDate: null }); + const current = hashMetadata({ title: v.title, description: v.renderedDescription ?? v.youtubeDescription ?? '', ...shared }); + pending = baseline !== current; + } + if (pending) pendingItems.push({ id: v.id, title: v.title }); + } + + const [recentlySynced, upcomingItems, upcomingCount] = await Promise.all([ + this.prisma.video.findMany({ + where: { channel: { teamId }, youtubeDeletedAt: null, lastSyncedAt: { not: null } }, + select: { id: true, title: true, lastSyncedAt: true }, + orderBy: { lastSyncedAt: 'desc' }, + take: 5, + }), + this.prisma.video.findMany({ + where: { channel: { teamId }, youtubeDeletedAt: null, scheduledAt: { gte: now } }, + select: { id: true, title: true, scheduledAt: true }, + orderBy: { scheduledAt: 'asc' }, + take: 5, + }), + this.prisma.video.count({ where: { channel: { teamId }, youtubeDeletedAt: null, scheduledAt: { gte: now } } }), + ]); + + return { + pendingSync: { count: pendingItems.length, items: pendingItems.slice(0, 5) }, + recentlySynced: { items: recentlySynced }, + upcomingScheduled: { count: upcomingCount, items: upcomingItems }, + }; + } + + async findOne(id: string, teamId: string) { + const video = await this.prisma.video.findFirst({ + where: { id, channel: { teamId } }, + include: { + template: true, + config: true, + lintResults: { where: { resolvedAt: null } }, + }, + }); + if (!video) throw new NotFoundException(`Video ${id} not found`); + return { ...video, hasPendingChanges: this.computePendingChanges(video as any) }; + } + + private computePendingChanges(v: any): boolean { + return computePendingChanges(v); + } + + async update(id: string, dto: UpdateVideoDto, actorId: string, teamId: string) { + const before = await this.findOne(id, teamId); + const { scheduledAt, recordingDate, collaboratorIds, ...rest } = dto; + const video = await this.prisma.video.update({ + where: { id }, + data: { + ...rest, + scheduledAt: scheduledAt ? new Date(scheduledAt) : undefined, + recordingDate: recordingDate ? new Date(recordingDate) : undefined, + ...(collaboratorIds !== undefined && { collaboratorIds }), + }, + }); + await this.audit.log(actorId, 'Video', id, 'update', before, video); + return video; + } + + async renderDescription(videoId: string, teamId: string) { + const exists = await this.prisma.video.findFirst({ where: { id: videoId, channel: { teamId } }, select: { id: true, config: { select: { videoId: true } } } }); + if (!exists) throw new NotFoundException(`Video ${videoId} not found`); + if (!exists.config) throw new NotFoundException('Video has no config'); + + const result = await this.videoRender.renderVideo(videoId); + if (!result) throw new NotFoundException('Video has no config'); + + // Read-only preview — do NOT persist renderedDescription here. + // Only the background render job and sync processor write renderedDescription, + // so the diff panel cannot accidentally flip hasPendingChanges. + return result; + } + + async refreshFromYouTube(id: string, teamId: string) { + const video = await this.prisma.video.findFirst({ + where: { id, channel: { teamId } }, + include: { channel: { select: { id: true } } }, + }); + if (!video) throw new NotFoundException(`Video ${id} not found`); + + const item = await this.ytApi.getVideoMetadata(video.youtubeVideoId, video.channel.id, { actionId: randomUUID(), actionType: 'video_refresh' }); + if (!item) throw new NotFoundException('Video not found on YouTube'); + + const snippet = item.snippet; + const status = item.status; + const recordingDetails = (item as any).recordingDetails; + const contentDetails = (item as any).contentDetails; + const privacyMap: Record = { public: 'PUBLIC', private: 'PRIVATE', unlisted: 'UNLISTED' }; + + const refreshedTitle = snippet?.title ?? video.title; + const refreshedTags = snippet?.tags ?? video.tags; + const refreshedCategoryId = snippet?.categoryId ?? video.categoryId; + const refreshedPrivacyStatus = privacyMap[status?.privacyStatus ?? ''] ?? video.privacyStatus; + const refreshedDefaultLanguage = snippet?.defaultLanguage ?? video.defaultLanguage; + const refreshedDefaultAudioLanguage = snippet?.defaultAudioLanguage ?? video.defaultAudioLanguage; + const refreshedSelfDeclaredMadeForKids = status?.selfDeclaredMadeForKids ?? video.selfDeclaredMadeForKids; + const refreshedEmbeddable = status?.embeddable ?? video.embeddable; + const refreshedLicense = status?.license ?? video.license; + const refreshedRecordingDate = recordingDetails?.recordingDate ? new Date(recordingDetails.recordingDate) : video.recordingDate; + + const refreshedYoutubeDescription = snippet?.description ?? null; + + const youtubeSnapshot = { + title: refreshedTitle, + tags: refreshedTags, + categoryId: refreshedCategoryId ?? null, + privacyStatus: String(refreshedPrivacyStatus), + defaultLanguage: refreshedDefaultLanguage ?? null, + defaultAudioLanguage: refreshedDefaultAudioLanguage ?? null, + selfDeclaredMadeForKids: refreshedSelfDeclaredMadeForKids, + embeddable: refreshedEmbeddable, + license: refreshedLicense, + recordingDate: refreshedRecordingDate ? refreshedRecordingDate.toISOString().slice(0, 10) : null, + }; + + // Reset lastSyncedHash to the hash of what YouTube currently has. This prevents + // hasPendingChanges from being incorrectly true after a refresh when YouTube returns + // values (e.g. categoryId) that were null in the DB but valid on YouTube's end. + const lastSyncedHash = hashMetadata({ + title: refreshedTitle, + description: refreshedYoutubeDescription ?? '', + tags: refreshedTags, + categoryId: refreshedCategoryId ?? null, + privacyStatus: refreshedPrivacyStatus ? String(refreshedPrivacyStatus) : null, + defaultLanguage: refreshedDefaultLanguage ?? null, + defaultAudioLanguage: refreshedDefaultAudioLanguage ?? null, + selfDeclaredMadeForKids: refreshedSelfDeclaredMadeForKids, + embeddable: refreshedEmbeddable, + license: refreshedLicense, + recordingDate: refreshedRecordingDate, + }); + + return this.prisma.video.update({ + where: { id }, + data: { + title: refreshedTitle, + youtubeDescription: refreshedYoutubeDescription, + tags: refreshedTags, + categoryId: refreshedCategoryId, + publishedAt: snippet?.publishedAt ? new Date(snippet.publishedAt) : video.publishedAt, + privacyStatus: refreshedPrivacyStatus, + scheduledAt: (status as any)?.publishAt ? new Date((status as any).publishAt) : video.scheduledAt, + thumbnailUrl: (() => { const t = (snippet as any)?.thumbnails; return t?.maxres?.url ?? t?.standard?.url ?? t?.high?.url ?? t?.medium?.url ?? t?.default?.url ?? video.thumbnailUrl; })(), + madeForKids: status?.madeForKids ?? video.madeForKids, + selfDeclaredMadeForKids: refreshedSelfDeclaredMadeForKids, + embeddable: refreshedEmbeddable, + license: refreshedLicense, + defaultLanguage: refreshedDefaultLanguage, + defaultAudioLanguage: refreshedDefaultAudioLanguage, + recordingDate: refreshedRecordingDate, + youtubeSnapshot, + lastSyncedHash, + }, + }); + } + + async acceptRemote(id: string, actorId: string, teamId: string) { + const video = await this.prisma.video.findFirst({ + where: { id, channel: { teamId } }, + }); + if (!video) throw new NotFoundException(`Video ${id} not found`); + if (!video.pendingRemoteSnapshot) { + throw new BadRequestException('No pending remote snapshot to accept'); + } + + const snap = video.pendingRemoteSnapshot as { + title: string; + tags: string[]; + categoryId: string | null; + privacyStatus: string | null; + defaultLanguage: string | null; + defaultAudioLanguage: string | null; + selfDeclaredMadeForKids: boolean; + embeddable: boolean; + license: string; + recordingDate: string | null; + }; + const pendingDescription = video.pendingRemoteDescription ?? ''; + const recordingDate = snap.recordingDate ? new Date(snap.recordingDate) : null; + const privacyStatus = (snap.privacyStatus ?? video.privacyStatus) as PrivacyStatus; + + const lastSyncedHash = hashMetadata({ + title: snap.title, + description: pendingDescription, + tags: snap.tags, + categoryId: snap.categoryId, + privacyStatus: String(privacyStatus), + defaultLanguage: snap.defaultLanguage, + defaultAudioLanguage: snap.defaultAudioLanguage, + selfDeclaredMadeForKids: snap.selfDeclaredMadeForKids, + embeddable: snap.embeddable, + license: snap.license, + recordingDate, + }); + + const updated = await this.prisma.video.update({ + where: { id }, + data: { + title: snap.title, + tags: snap.tags, + categoryId: snap.categoryId, + privacyStatus, + defaultLanguage: snap.defaultLanguage, + defaultAudioLanguage: snap.defaultAudioLanguage, + selfDeclaredMadeForKids: snap.selfDeclaredMadeForKids, + embeddable: snap.embeddable, + license: snap.license, + recordingDate, + youtubeDescription: pendingDescription, + renderedDescription: pendingDescription, + youtubeSnapshot: video.pendingRemoteSnapshot as Prisma.InputJsonValue, + lastSyncedHash, + lastSyncedAt: new Date(), + remoteConflict: false, + pendingRemoteSnapshot: Prisma.JsonNull, + pendingRemoteDescription: null, + }, + }); + + await this.audit.log(actorId, 'Video', id, 'accept-remote', video, updated); + return updated; + } + + async enqueueSyncJob(videoId: string) { + const jobId = `sync-${videoId}`; + // BullMQ deduplicates by jobId across all states (failed, waiting, delayed, etc.). + // Remove any stale job so the user always gets a fresh enqueue. Never remove active + // jobs — the worker is mid-processing and removing would leave the DB in a partial state. + const existing = await this.syncQueue.getJob(jobId); + if (existing) { + const state = await existing.getState(); + if (state !== 'active') await existing.remove(); + } + await this.syncQueue.add('sync', { videoId }, { jobId, removeOnComplete: true, removeOnFail: { count: 5 } }); + return { queued: true }; + } + + async bulkPreview(dto: BulkPreviewDto, actorId: string, teamId: string) { + const videoIds = await this.resolveVideoIds(dto, teamId); + const previews = await Promise.all( + videoIds.map(async (id) => { + const before = await this.prisma.video.findUnique({ + where: { id }, + select: { id: true, title: true, tags: true, privacyStatus: true, templateId: true }, + }); + return { videoId: id, before, after: this.applyBulkAction(before, dto.type, dto.payload) }; + }), + ); + return { type: dto.type, count: videoIds.length, previews }; + } + + async bulkApply(dto: BulkPreviewDto, actorId: string, teamId: string) { + const videoIds = await this.resolveVideoIds(dto, teamId); + const bulkJob = await this.prisma.bulkJob.create({ + data: { + teamId, + type: dto.type, + initiatedBy: actorId, + filterSnapshot: dto as any, + targetIds: videoIds, + status: 'CONFIRMED', + totalCount: videoIds.length, + items: { create: videoIds.map((videoId) => ({ videoId, status: 'pending' })) }, + }, + include: { items: true }, + }); + return { bulkJobId: bulkJob.id, count: videoIds.length }; + } + + private async resolveVideoIds(dto: BulkPreviewDto, teamId: string): Promise { + if (dto.videoIds?.length) return dto.videoIds; + if (dto.savedViewId) { + const view = await this.prisma.savedView.findFirstOrThrow({ where: { id: dto.savedViewId, teamId } }); + const filter = { ...(view.queryJson as any), channel: { teamId } }; + const videos = await this.prisma.video.findMany({ where: filter, select: { id: true } }); + return videos.map((v) => v.id); + } + throw new BadRequestException('Provide videoIds or savedViewId'); + } + + private applyBulkAction(video: any, type: string, payload: any): any { + const after = { ...video }; + switch (type) { + case 'SET_PRIVACY': after.privacyStatus = payload.privacyStatus; break; + case 'SET_TEMPLATE': after.templateId = payload.templateId; break; + case 'ADD_TAGS': after.tags = [...(after.tags ?? []), ...(payload.tags ?? [])]; break; + case 'REMOVE_TAGS': after.tags = (after.tags ?? []).filter((t: string) => !(payload.tags ?? []).includes(t)); break; + case 'SEARCH_REPLACE_TITLE': after.title = after.title?.replace(payload.search, payload.replace); break; + } + return after; + } +} diff --git a/backend/src/modules/youtube-sync/channel-import.service.ts b/backend/src/modules/youtube-sync/channel-import.service.ts new file mode 100644 index 0000000..15a1271 --- /dev/null +++ b/backend/src/modules/youtube-sync/channel-import.service.ts @@ -0,0 +1,476 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { YouTubeApiClient } from './youtube-api.client'; +import { hashMetadata } from '../../shared/render-engine/hash'; +import { PrivacyStatus } from '@prisma/client'; + +const YT_PRIVACY: Record = { + public: PrivacyStatus.PUBLIC, + private: PrivacyStatus.PRIVATE, + unlisted: PrivacyStatus.UNLISTED, +}; + +type ActionCtx = { actionId?: string; actionType?: string }; + +@Injectable() +export class ChannelImportService { + private readonly logger = new Logger(ChannelImportService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly ytApi: YouTubeApiClient, + ) {} + + async importChannel( + channelId: string, + teamId: string, + ): Promise<{ total: number; created: number; updated: number; deleted: number; deletedTitles: string[] }> { + this.logger.log(`Channel import started for channel ${channelId}`); + + const ctx: ActionCtx = { actionId: randomUUID(), actionType: 'channel_import' }; + + const channel = await this.prisma.channel.findFirstOrThrow({ + where: { id: channelId, teamId }, + }); + + const playlistId = + channel.uploadsPlaylistId ?? (await this.ytApi.getUploadsPlaylistId(channelId, ctx)); + + const { ids: rawIds, pageCount } = await this.ytApi.listPlaylistVideoIds(channelId, playlistId, ctx); + const playlistIds = new Set(rawIds); + if (rawIds.length !== playlistIds.size) { + const seen = new Set(); + const dupes = rawIds.filter((id) => seen.size === seen.add(id).size); + this.logger.warn(`Playlist has ${rawIds.length - playlistIds.size} duplicate entries: ${[...new Set(dupes)].join(', ')}`); + } + + const supplemental = channel.supplementalVideoIds ?? []; + const newSupplemental = supplemental.filter((id) => !playlistIds.has(id)); + if (newSupplemental.length > 0) { + this.logger.log(`Adding ${newSupplemental.length} supplemental video IDs not in playlist: ${newSupplemental.join(', ')}`); + } + + const ids = [...playlistIds, ...newSupplemental]; + this.logger.log(`Found ${playlistIds.size} unique videos (${rawIds.length} playlist entries) across ${pageCount} pages, plus ${newSupplemental.length} supplemental`); + + let created = 0; + let updated = 0; + + for (let i = 0; i < ids.length; i += 50) { + const batch = ids.slice(i, i + 50); + const items = await this.ytApi.getVideosBatch(batch, channelId, ctx); + + const returnedIds = new Set(items.map((item) => item.id)); + const missing = batch.filter((id) => !returnedIds.has(id)); + if (missing.length > 0) { + this.logger.warn(`videos.list did not return ${missing.length} IDs: ${missing.join(', ')}`); + } + + for (const item of items) { + const result = await this.upsertVideoFromYouTubeItem(item, channelId); + if (result === 'created') created++; + else if (result === 'updated') updated++; + } + } + + await this.syncPlaylistMembership(channelId, ctx); + + const purgeResult = await this._purgeForChannel(channelId); + + this.logger.log(`Import complete: ${created} created, ${updated} updated, ${purgeResult.softDeleted} soft-deleted, ${purgeResult.hardDeleted} hard-deleted, ${purgeResult.restored} restored`); + + return { total: ids.length, created, updated, deleted: purgeResult.softDeleted + purgeResult.hardDeleted, deletedTitles: purgeResult.softDeletedTitles }; + } + + async importSpecificVideos( + channelId: string, + teamId: string, + videoIds: string[], + ): Promise<{ total: number; created: number; updated: number; notFound: string[] }> { + this.logger.log(`Importing ${videoIds.length} specific video IDs for channel ${channelId}`); + + const ctx: ActionCtx = { actionId: randomUUID(), actionType: 'channel_import' }; + + await this.prisma.channel.findFirstOrThrow({ where: { id: channelId, teamId } }); + + const unique = [...new Set(videoIds)]; + let created = 0; + let updated = 0; + const notFoundIds: string[] = []; + + for (let i = 0; i < unique.length; i += 50) { + const batch = unique.slice(i, i + 50); + const items = await this.ytApi.getVideosBatch(batch, channelId, ctx); + + const returnedIds = new Set(items.map((item) => item.id)); + notFoundIds.push(...batch.filter((id) => !returnedIds.has(id))); + + for (const item of items) { + const result = await this.upsertVideoFromYouTubeItem(item, channelId); + if (result === 'created') created++; + else if (result === 'updated') updated++; + } + } + + if (notFoundIds.length > 0) { + this.logger.warn(`YouTube did not return ${notFoundIds.length} IDs: ${notFoundIds.join(', ')}`); + } + this.logger.log(`Specific import complete: ${created} created, ${updated} updated, ${notFoundIds.length} not found`); + return { total: unique.length, created, updated, notFound: notFoundIds }; + } + + async fullRefreshChannel( + channelId: string, + teamId: string, + ): Promise<{ + total: number; + created: number; + updated: number; + duplicateUploadsEntries: number; + notFoundOnYouTube: string[]; + playlistsForceSynced: number; + orphansImported: number; + orphansRejected: number; + deleted: number; + deletedTitles: string[]; + }> { + this.logger.log(`Full refresh started for channel ${channelId}`); + + const ctx: ActionCtx = { actionId: randomUUID(), actionType: 'channel_import' }; + + const channel = await this.prisma.channel.findFirstOrThrow({ + where: { id: channelId, teamId }, + }); + + const playlistId = + channel.uploadsPlaylistId ?? (await this.ytApi.getUploadsPlaylistId(channelId, ctx)); + + const { ids: rawIds, pageCount } = await this.ytApi.listPlaylistVideoIds(channelId, playlistId, ctx); + const uploadsIds = new Set(rawIds); + + const duplicateUploadsEntries = rawIds.length - uploadsIds.size; + if (duplicateUploadsEntries > 0) { + const seen = new Set(); + const dupes = rawIds.filter((id) => seen.size === seen.add(id).size); + this.logger.warn(`Uploads playlist has ${duplicateUploadsEntries} duplicate entries: ${[...new Set(dupes)].join(', ')}`); + } + + const supplemental = channel.supplementalVideoIds ?? []; + const newSupplemental = supplemental.filter((id) => !uploadsIds.has(id)); + const ids = [...uploadsIds, ...newSupplemental]; + + this.logger.log(`Full refresh: ${uploadsIds.size} unique videos (${rawIds.length} entries) across ${pageCount} pages, plus ${newSupplemental.length} supplemental`); + + let created = 0; + let updated = 0; + const notFoundOnYouTube: string[] = []; + + for (let i = 0; i < ids.length; i += 50) { + const batch = ids.slice(i, i + 50); + const items = await this.ytApi.getVideosBatch(batch, channelId, ctx); + + const returnedIds = new Set(items.map((item) => item.id)); + notFoundOnYouTube.push(...batch.filter((id) => !returnedIds.has(id))); + + for (const item of items) { + const result = await this.upsertVideoFromYouTubeItem(item, channelId); + if (result === 'created') created++; + else if (result === 'updated') updated++; + } + } + + if (notFoundOnYouTube.length > 0) { + this.logger.warn(`YouTube did not return ${notFoundOnYouTube.length} IDs during full refresh: ${notFoundOnYouTube.join(', ')}`); + } + + // Force-sync all playlists and collect every video ID seen across them + const { synced: playlistsForceSynced, allVideoIds: playlistVideoIds } = + await this.syncPlaylistMembership(channelId, ctx, true); + + // Find IDs that appear in other playlists but not in the uploads playlist or supplemental list + const knownIds = new Set([...uploadsIds, ...supplemental]); + const orphanCandidates = [...playlistVideoIds].filter((id) => !knownIds.has(id)); + + let orphansImported = 0; + let orphansRejected = 0; + + if (orphanCandidates.length > 0) { + this.logger.log(`Found ${orphanCandidates.length} playlist video IDs not in uploads playlist — verifying channel ownership`); + + for (let i = 0; i < orphanCandidates.length; i += 50) { + const batch = orphanCandidates.slice(i, i + 50); + const items = await this.ytApi.getVideosBatch(batch, channelId, ctx); + + for (const item of items) { + // Only import videos that actually belong to this channel + if (item.snippet?.channelId !== channel.youtubeChannelId) { + this.logger.log(`Skipping ${item.id} — belongs to channel ${item.snippet?.channelId}, not ${channel.youtubeChannelId}`); + orphansRejected++; + continue; + } + const result = await this.upsertVideoFromYouTubeItem(item, channelId); + if (result === 'created' || result === 'updated') { + orphansImported++; + created += result === 'created' ? 1 : 0; + updated += result === 'updated' ? 1 : 0; + } + } + } + + if (orphansImported > 0) { + this.logger.log(`Imported ${orphansImported} orphaned videos found in playlists but not in uploads playlist`); + } + if (orphansRejected > 0) { + this.logger.warn(`Rejected ${orphansRejected} playlist videos — belong to a different channel`); + } + } + + const purgeResult = await this._purgeForChannel(channelId); + + this.logger.log(`Full refresh complete: ${created} created, ${updated} updated, ${playlistsForceSynced} playlists force-synced, ${orphansImported} orphans imported, ${purgeResult.softDeleted} soft-deleted, ${purgeResult.hardDeleted} hard-deleted, ${purgeResult.restored} restored`); + + return { + total: ids.length + orphansImported, + created, + updated, + duplicateUploadsEntries, + notFoundOnYouTube, + playlistsForceSynced, + orphansImported, + orphansRejected, + deleted: purgeResult.softDeleted + purgeResult.hardDeleted, + deletedTitles: purgeResult.softDeletedTitles, + }; + } + + async purgeDeletedVideos(channelId: string, teamId: string) { + await this.prisma.channel.findFirstOrThrow({ where: { id: channelId, teamId } }); + return this._purgeForChannel(channelId); + } + + private async _purgeForChannel(channelId: string): Promise<{ checked: number; softDeleted: number; restored: number; hardDeleted: number; softDeletedTitles: string[] }> { + const localVideos = await this.prisma.video.findMany({ + where: { channelId }, + select: { id: true, youtubeVideoId: true, title: true, youtubeDeletedAt: true }, + }); + + const ctx: ActionCtx = { actionId: randomUUID(), actionType: 'purge_deleted' }; + const softDeleteIds: string[] = []; + const softDeletedTitles: string[] = []; + const hardDeleteIds: string[] = []; + const restoreIds: string[] = []; + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + + for (let i = 0; i < localVideos.length; i += 50) { + const batch = localVideos.slice(i, i + 50); + const items = await this.ytApi.getVideosBatch(batch.map((v) => v.youtubeVideoId), channelId, ctx); + // Videos returned with uploadStatus !== 'deleted' are live on YouTube + const liveYtIds = new Set( + items.filter((item) => item.status?.uploadStatus !== 'deleted').map((item) => item.id), + ); + for (const v of batch) { + if (!liveYtIds.has(v.youtubeVideoId)) { + if (v.youtubeDeletedAt && v.youtubeDeletedAt < thirtyDaysAgo) { + hardDeleteIds.push(v.id); + } else if (!v.youtubeDeletedAt) { + softDeleteIds.push(v.id); + softDeletedTitles.push(v.title); + } + // already soft-deleted and within 30 days — no action + } else if (v.youtubeDeletedAt) { + restoreIds.push(v.id); + } + } + } + + if (softDeleteIds.length > 0) { + await this.prisma.video.updateMany({ where: { id: { in: softDeleteIds } }, data: { youtubeDeletedAt: new Date() } }); + this.logger.log(`Soft-deleted ${softDeleteIds.length} videos from channel ${channelId}: ${softDeletedTitles.join(', ')}`); + } + if (hardDeleteIds.length > 0) { + await this.prisma.video.deleteMany({ where: { id: { in: hardDeleteIds } } }); + this.logger.log(`Hard-deleted ${hardDeleteIds.length} expired videos from channel ${channelId}`); + } + if (restoreIds.length > 0) { + await this.prisma.video.updateMany({ where: { id: { in: restoreIds } }, data: { youtubeDeletedAt: null } }); + this.logger.log(`Restored ${restoreIds.length} videos for channel ${channelId}`); + } + + return { + checked: localVideos.length, + softDeleted: softDeleteIds.length, + restored: restoreIds.length, + hardDeleted: hardDeleteIds.length, + softDeletedTitles, + }; + } + + private async upsertVideoFromYouTubeItem( + item: any, + channelId: string, + ): Promise<'created' | 'updated' | 'skipped'> { + const ytId = item.id; + if (!ytId) return 'skipped'; + + const snippet = item.snippet; + const status = item.status; + + const title = snippet?.title ?? '(Untitled)'; + const youtubeDescription = snippet?.description ?? undefined; + const tags = snippet?.tags ?? []; + const categoryId = snippet?.categoryId ?? undefined; + const publishedAt = snippet?.publishedAt ? new Date(snippet.publishedAt) : undefined; + const privacyStatus: PrivacyStatus = + YT_PRIVACY[status?.privacyStatus ?? ''] ?? PrivacyStatus.PRIVATE; + const defaultLanguage = snippet?.defaultLanguage ?? undefined; + const defaultAudioLanguage = snippet?.defaultAudioLanguage ?? undefined; + const selfDeclaredMadeForKids = (status as any)?.selfDeclaredMadeForKids ?? undefined; + const embeddable = (status as any)?.embeddable ?? undefined; + const license = (status as any)?.license ?? undefined; + const scheduledAt = (status as any)?.publishAt ? new Date((status as any).publishAt) : undefined; + const thumbnails = snippet?.thumbnails as any; + const thumbnailUrl = + thumbnails?.maxres?.url ?? + thumbnails?.standard?.url ?? + thumbnails?.high?.url ?? + thumbnails?.medium?.url ?? + thumbnails?.default?.url ?? + undefined; + + const ytFields = { + title, youtubeDescription, tags, categoryId, publishedAt, privacyStatus, + defaultLanguage, defaultAudioLanguage, selfDeclaredMadeForKids, embeddable, license, + scheduledAt, thumbnailUrl, + youtubeDeletedAt: null, + }; + + const existing = await this.prisma.video.findUnique({ where: { youtubeVideoId: ytId } }); + + // For fields YouTube doesn't reliably return, fall back to existing DB value so + // the import hash stays consistent with what computePendingChanges will compute. + // recordingDate is never returned by the API, so preserving it prevents false + // "push pending" after re-import. + const resolvedFields = { + defaultLanguage: defaultLanguage ?? existing?.defaultLanguage ?? null, + defaultAudioLanguage: defaultAudioLanguage ?? existing?.defaultAudioLanguage ?? null, + selfDeclaredMadeForKids: selfDeclaredMadeForKids ?? existing?.selfDeclaredMadeForKids ?? false, + embeddable: embeddable ?? existing?.embeddable ?? true, + license: license ?? existing?.license ?? 'youtube', + recordingDate: existing?.recordingDate ?? null, + }; + + const importHash = hashMetadata({ + title, + description: youtubeDescription ?? '', + tags, + categoryId: categoryId ?? null, + privacyStatus: privacyStatus ? String(privacyStatus) : null, + ...resolvedFields, + }); + + const youtubeSnapshot = { + title, + tags, + categoryId: categoryId ?? null, + privacyStatus: String(privacyStatus), + defaultLanguage: resolvedFields.defaultLanguage, + defaultAudioLanguage: resolvedFields.defaultAudioLanguage, + selfDeclaredMadeForKids: resolvedFields.selfDeclaredMadeForKids, + embeddable: resolvedFields.embeddable, + license: resolvedFields.license, + recordingDate: resolvedFields.recordingDate + ? resolvedFields.recordingDate.toISOString().slice(0, 10) + : null, + }; + + if (!existing) { + await this.prisma.video.create({ + data: { youtubeVideoId: ytId, channelId, ...ytFields, lastSyncedHash: importHash, youtubeSnapshot }, + }); + return 'created'; + } else { + // Always update channelId in case the video was previously imported under a + // stale/wrong channel — this re-assigns ownership to the correct channel. + await this.prisma.video.update({ + where: { youtubeVideoId: ytId }, + data: { channelId, ...ytFields, lastSyncedHash: importHash, youtubeSnapshot }, + }); + return 'updated'; + } + } + + private async syncPlaylistMembership( + channelId: string, + ctx?: ActionCtx, + force = false, + ): Promise<{ synced: number; skipped: number; allVideoIds: Set }> { + const ytPlaylists = await this.ytApi.listChannelPlaylists(channelId, ctx); + + const existingPlaylists = await this.prisma.playlist.findMany({ + where: { channelId }, + select: { id: true, youtubePlaylistId: true, itemCount: true }, + }); + const existingMap = new Map(existingPlaylists.map((pl) => [pl.youtubePlaylistId, pl])); + + for (const pl of ytPlaylists) { + await this.prisma.playlist.upsert({ + where: { youtubePlaylistId: pl.youtubePlaylistId }, + create: { channelId, ...pl }, + update: { title: pl.title, description: pl.description, itemCount: pl.itemCount, privacyStatus: pl.privacyStatus }, + }); + } + + const dbPlaylists = await this.prisma.playlist.findMany({ + where: { channelId }, + select: { id: true, youtubePlaylistId: true }, + }); + const playlistIdMap = new Map(dbPlaylists.map((pl) => [pl.youtubePlaylistId, pl.id])); + + let skipped = 0; + let synced = 0; + const allVideoIds = new Set(); + + for (const pl of ytPlaylists) { + const dbPlaylistId = playlistIdMap.get(pl.youtubePlaylistId); + if (!dbPlaylistId) continue; + + const existing = existingMap.get(pl.youtubePlaylistId); + if (!force && existing && existing.itemCount === pl.itemCount) { + skipped++; + continue; + } + + const { ids: ytVideoIds } = await this.ytApi.listPlaylistVideoIds(channelId, pl.youtubePlaylistId, ctx); + + for (const id of ytVideoIds) allVideoIds.add(id); + + const matchingVideos = await this.prisma.video.findMany({ + where: { youtubeVideoId: { in: ytVideoIds }, channelId }, + select: { id: true }, + }); + + await this.prisma.videoPlaylist.deleteMany({ where: { playlistId: dbPlaylistId } }); + if (matchingVideos.length > 0) { + await this.prisma.videoPlaylist.createMany({ + data: matchingVideos.map((v) => ({ videoId: v.id, playlistId: dbPlaylistId })), + skipDuplicates: true, + }); + } + synced++; + } + + this.logger.log(`Playlist membership synced: ${synced} updated, ${skipped} skipped (itemCount unchanged)`); + return { synced, skipped, allVideoIds }; + } + + async addSupplementalVideoIds(channelId: string, teamId: string, videoIds: string[]): Promise { + const channel = await this.prisma.channel.findFirstOrThrow({ where: { id: channelId, teamId } }); + const existing = new Set(channel.supplementalVideoIds ?? []); + for (const id of videoIds) existing.add(id); + const updated = [...existing]; + await this.prisma.channel.update({ where: { id: channelId }, data: { supplementalVideoIds: updated } }); + this.logger.log(`Channel ${channelId} supplemental IDs updated: ${updated.length} total`); + return updated; + } +} diff --git a/backend/src/modules/youtube-sync/youtube-api.client.ts b/backend/src/modules/youtube-sync/youtube-api.client.ts new file mode 100644 index 0000000..40c0f23 --- /dev/null +++ b/backend/src/modules/youtube-sync/youtube-api.client.ts @@ -0,0 +1,239 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { google } from 'googleapis'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { AuthService } from '../auth/auth.service'; +import { QuotaService } from '../../shared/quota/quota.service'; +import { PrivacyStatus } from '@prisma/client'; + +const PRIVACY_MAP: Record = { + PUBLIC: 'public', + PRIVATE: 'private', + UNLISTED: 'unlisted', +}; + +@Injectable() +export class YouTubeApiClient { + constructor( + private readonly prisma: PrismaService, + private readonly authService: AuthService, + private readonly config: ConfigService, + private readonly quota: QuotaService, + ) {} + + private async getYouTubeClient(channelId: string) { + const channel = await this.prisma.channel.findUniqueOrThrow({ where: { id: channelId } }); + + const oauth2 = new google.auth.OAuth2( + this.config.getOrThrow('GOOGLE_CLIENT_ID'), + this.config.getOrThrow('GOOGLE_CLIENT_SECRET'), + ); + + const accessToken = this.authService.decryptToken(channel.youtubeAccessToken!); + const refreshToken = channel.youtubeRefreshToken + ? this.authService.decryptToken(channel.youtubeRefreshToken) + : undefined; + + oauth2.setCredentials({ access_token: accessToken, refresh_token: refreshToken }); + + // Persist rotated tokens back to the Channel record + oauth2.on('tokens', async (tokens) => { + await this.prisma.channel.update({ + where: { id: channelId }, + data: { + ...(tokens.access_token && { + youtubeAccessToken: this.authService.encryptToken(tokens.access_token), + youtubeTokenExpiry: tokens.expiry_date ? new Date(tokens.expiry_date) : undefined, + }), + ...(tokens.refresh_token && { + youtubeRefreshToken: this.authService.encryptToken(tokens.refresh_token), + }), + }, + }); + }); + + return google.youtube({ version: 'v3', auth: oauth2 }); + } + + async getUploadsPlaylistId(channelId: string, ctx?: { actionId?: string; actionType?: string }): Promise { + const yt = await this.getYouTubeClient(channelId); + const res = await yt.channels.list({ part: ['contentDetails'], mine: true }); + await this.quota.spend(1, 'channels.list', channelId, undefined, undefined, undefined, ctx?.actionId, ctx?.actionType); + const playlistId = res.data.items?.[0]?.contentDetails?.relatedPlaylists?.uploads; + if (!playlistId) throw new Error('Could not find uploads playlist for channel'); + + // Cache on the channel record to save quota on future calls + await this.prisma.channel.update({ + where: { id: channelId }, + data: { uploadsPlaylistId: playlistId }, + }); + + return playlistId; + } + + async listPlaylistVideoIds( + channelId: string, + playlistId: string, + ctx?: { actionId?: string; actionType?: string }, + ): Promise<{ ids: string[]; pageCount: number }> { + const yt = await this.getYouTubeClient(channelId); + const ids: string[] = []; + let pageToken: string | undefined; + let pageCount = 0; + + do { + const res = await yt.playlistItems.list({ + part: ['contentDetails'], + playlistId, + maxResults: 50, + ...(pageToken ? { pageToken } : {}), + }); + pageCount++; + await this.quota.spend(1, 'playlistItems.list', channelId, undefined, undefined, playlistId, ctx?.actionId, ctx?.actionType); + for (const item of res.data.items ?? []) { + const vid = item.contentDetails?.videoId; + if (vid) ids.push(vid); + } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); + + return { ids, pageCount }; + } + + async getVideosBatch(youtubeVideoIds: string[], channelId: string, ctx?: { actionId?: string; actionType?: string }) { + const yt = await this.getYouTubeClient(channelId); + const res = await yt.videos.list({ + part: ['snippet', 'status'], + id: youtubeVideoIds, + maxResults: 50, + }); + await this.quota.spend(1, 'videos.list', channelId, undefined, undefined, `batch:${youtubeVideoIds.length}`, ctx?.actionId, ctx?.actionType); + return res.data.items ?? []; + } + + async getVideoMetadata(youtubeVideoId: string, channelId: string, ctx?: { actionId?: string; actionType?: string }) { + const yt = await this.getYouTubeClient(channelId); + const res = await yt.videos.list({ + part: ['snippet', 'status', 'recordingDetails', 'contentDetails'], + id: [youtubeVideoId], + }); + await this.quota.spend(1, 'videos.list', channelId, undefined, undefined, youtubeVideoId, ctx?.actionId, ctx?.actionType); + return res.data.items?.[0] ?? null; + } + + async listChannelPlaylists(channelId: string, ctx?: { actionId?: string; actionType?: string }): Promise<{ youtubePlaylistId: string; title: string; description: string; itemCount: number; privacyStatus: string }[]> { + const yt = await this.getYouTubeClient(channelId); + const results: any[] = []; + let pageToken: string | undefined; + + do { + const res = await yt.playlists.list({ + part: ['snippet', 'status', 'contentDetails'], + mine: true, + maxResults: 50, + ...(pageToken ? { pageToken } : {}), + }); + await this.quota.spend(1, 'playlists.list', channelId, undefined, undefined, undefined, ctx?.actionId, ctx?.actionType); + for (const item of res.data.items ?? []) { + results.push({ + youtubePlaylistId: item.id!, + title: item.snippet?.title ?? '', + description: item.snippet?.description ?? '', + itemCount: item.contentDetails?.itemCount ?? 0, + privacyStatus: item.status?.privacyStatus ?? 'public', + }); + } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); + + return results; + } + + async addVideoToPlaylist(youtubeVideoId: string, youtubePlaylistId: string, channelId: string, ctx?: { actionId?: string; actionType?: string }): Promise { + const yt = await this.getYouTubeClient(channelId); + const res = await yt.playlistItems.insert({ + part: ['snippet'], + requestBody: { + snippet: { + playlistId: youtubePlaylistId, + resourceId: { kind: 'youtube#video', videoId: youtubeVideoId }, + }, + }, + }); + await this.quota.spend(50, 'playlistItems.insert', channelId, undefined, undefined, youtubePlaylistId, ctx?.actionId, ctx?.actionType); + return res.data.id!; + } + + async removeVideoFromPlaylist(youtubeVideoId: string, youtubePlaylistId: string, channelId: string, ctx?: { actionId?: string; actionType?: string }): Promise { + const yt = await this.getYouTubeClient(channelId); + const res = await yt.playlistItems.list({ + part: ['id'], + playlistId: youtubePlaylistId, + videoId: youtubeVideoId, + }); + await this.quota.spend(1, 'playlistItems.list', channelId, undefined, undefined, youtubePlaylistId, ctx?.actionId, ctx?.actionType); + const itemId = res.data.items?.[0]?.id; + if (!itemId) return; + await yt.playlistItems.delete({ id: itemId }); + await this.quota.spend(50, 'playlistItems.delete', channelId, undefined, undefined, youtubePlaylistId, ctx?.actionId, ctx?.actionType); + } + + async updateVideoMetadata( + youtubeVideoId: string, + meta: { + title: string; + description: string; + tags: string[]; + categoryId?: string; + privacyStatus: PrivacyStatus; + scheduledAt?: Date | null; + defaultLanguage?: string; + defaultAudioLanguage?: string; + selfDeclaredMadeForKids?: boolean; + embeddable?: boolean; + license?: string; + recordingDate?: Date | null; + }, + channelId: string, + ctx?: { actionId?: string; actionType?: string }, + ) { + const yt = await this.getYouTubeClient(channelId); + + const parts: string[] = ['snippet', 'status']; + if (meta.recordingDate !== undefined) parts.push('recordingDetails'); + + // Preserve scheduled publishing: YouTube represents a scheduled video as + // privacyStatus=private + publishAt. If we send privacyStatus=private without + // publishAt, YouTube silently cancels the schedule. + const isFutureSchedule = meta.scheduledAt && meta.scheduledAt > new Date(); + + const result = await yt.videos.update({ + part: parts, + requestBody: { + id: youtubeVideoId, + snippet: { + title: meta.title, + description: meta.description, + tags: meta.tags, + categoryId: meta.categoryId, + ...(meta.defaultLanguage !== undefined && { defaultLanguage: meta.defaultLanguage || undefined }), + ...(meta.defaultAudioLanguage !== undefined && { defaultAudioLanguage: meta.defaultAudioLanguage || undefined }), + }, + status: { + privacyStatus: PRIVACY_MAP[meta.privacyStatus], + ...(isFutureSchedule && { publishAt: meta.scheduledAt!.toISOString() }), + ...(meta.selfDeclaredMadeForKids !== undefined && { selfDeclaredMadeForKids: meta.selfDeclaredMadeForKids }), + ...(meta.embeddable !== undefined && { embeddable: meta.embeddable }), + ...(meta.license !== undefined && { license: meta.license }), + }, + ...(meta.recordingDate !== undefined && { + recordingDetails: { + recordingDate: meta.recordingDate ? meta.recordingDate.toISOString().slice(0, 10) : undefined, + }, + }), + }, + }); + await this.quota.spend(50, 'videos.update', channelId, undefined, undefined, youtubeVideoId, ctx?.actionId, ctx?.actionType); + return result; + } +} diff --git a/backend/src/modules/youtube-sync/youtube-sync.controller.ts b/backend/src/modules/youtube-sync/youtube-sync.controller.ts new file mode 100644 index 0000000..2c04157 --- /dev/null +++ b/backend/src/modules/youtube-sync/youtube-sync.controller.ts @@ -0,0 +1,115 @@ +import { Controller, Get, Post, Param, UseGuards, Req, Body, HttpCode, HttpStatus } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { TeamRole } from '@prisma/client'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { ChannelImportService } from './channel-import.service'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { QUEUES } from '../../queues/queues.constants'; + +@ApiTags('youtube-sync') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('youtube-sync') +export class YouTubeSyncController { + constructor( + private readonly importService: ChannelImportService, + private readonly prisma: PrismaService, + @InjectQueue(QUEUES.YOUTUBE_SYNC) private readonly syncQueue: Queue, + ) {} + + @Get('queue-status') + @ApiOperation({ summary: 'Current state of the YouTube sync queue' }) + async queueStatus() { + const [active, waiting, failed, completed] = await Promise.all([ + this.syncQueue.getActive(), + this.syncQueue.getWaiting(), + this.syncQueue.getFailed(0, 10), + this.syncQueue.getCompleted(0, 5), + ]); + + const allVideoIds = [...new Set( + [...active, ...waiting, ...failed, ...completed] + .map((j) => j.data?.videoId as string) + .filter(Boolean), + )]; + + const videos = await this.prisma.video.findMany({ + where: { id: { in: allVideoIds } }, + select: { id: true, title: true }, + }); + const titleMap = new Map(videos.map((v) => [v.id, v.title])); + + const mapJob = (j: any) => ({ + jobId: String(j.id), + videoId: j.data?.videoId as string, + videoTitle: titleMap.get(j.data?.videoId) ?? 'Unknown', + addedAt: j.timestamp ? new Date(j.timestamp).toISOString() : null, + }); + + return { + active: active.map(mapJob), + waiting: waiting.map(mapJob), + recentFailed: failed.map((j) => ({ + ...mapJob(j), + failedReason: j.failedReason ?? null, + failedAt: j.finishedOn ? new Date(j.finishedOn).toISOString() : null, + })), + recentCompleted: completed.map((j) => ({ + ...mapJob(j), + completedAt: j.finishedOn ? new Date(j.finishedOn).toISOString() : null, + })), + }; + } + + @Post('channel-import') + @Roles(TeamRole.EDITOR, TeamRole.ADMIN, TeamRole.OWNER) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Pull all videos from a connected YouTube channel' }) + runChannelImport(@Req() req: any, @Body('channelId') channelId: string) { + return this.importService.importChannel(channelId, req.user.teamId); + } + + @Post('import-video-ids') + @Roles(TeamRole.ADMIN, TeamRole.OWNER) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Immediately import specific YouTube video IDs into the DB' }) + importVideoIds( + @Req() req: any, + @Body('channelId') channelId: string, + @Body('videoIds') videoIds: string[], + ) { + return this.importService.importSpecificVideos(channelId, req.user.teamId, videoIds); + } + + @Post('channel-full-refresh') + @Roles(TeamRole.ADMIN, TeamRole.OWNER) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Force-reimport all videos and playlists for a channel (ignores itemCount cache)' }) + fullRefreshChannel(@Req() req: any, @Body('channelId') channelId: string) { + return this.importService.fullRefreshChannel(channelId, req.user.teamId); + } + + @Post('channel-purge-deleted') + @Roles(TeamRole.ADMIN, TeamRole.OWNER) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Delete local videos that no longer exist on YouTube' }) + purgeDeleted(@Req() req: any, @Body('channelId') channelId: string) { + return this.importService.purgeDeletedVideos(channelId, req.user.teamId); + } + + @Post('channels/:channelId/supplemental-ids') + @Roles(TeamRole.ADMIN, TeamRole.OWNER) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Add video IDs to the channel supplemental list (always imported regardless of playlist membership)' }) + addSupplementalIds( + @Param('channelId') channelId: string, + @Req() req: any, + @Body('videoIds') videoIds: string[], + ) { + return this.importService.addSupplementalVideoIds(channelId, req.user.teamId, videoIds); + } +} diff --git a/backend/src/modules/youtube-sync/youtube-sync.module.ts b/backend/src/modules/youtube-sync/youtube-sync.module.ts new file mode 100644 index 0000000..a64e5d0 --- /dev/null +++ b/backend/src/modules/youtube-sync/youtube-sync.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { YouTubeSyncService } from './youtube-sync.service'; +import { YouTubeApiClient } from './youtube-api.client'; +import { ChannelImportService } from './channel-import.service'; +import { YouTubeSyncController } from './youtube-sync.controller'; +import { QuotaModule } from '../../shared/quota/quota.module'; +import { AuthModule } from '../auth/auth.module'; +import { QUEUES } from '../../queues/queues.constants'; + +@Module({ + imports: [QuotaModule, AuthModule, BullModule.registerQueue({ name: QUEUES.YOUTUBE_SYNC })], + controllers: [YouTubeSyncController], + providers: [YouTubeSyncService, YouTubeApiClient, ChannelImportService], + exports: [YouTubeSyncService, YouTubeApiClient, ChannelImportService], +}) +export class YouTubeSyncModule {} diff --git a/backend/src/modules/youtube-sync/youtube-sync.service.ts b/backend/src/modules/youtube-sync/youtube-sync.service.ts new file mode 100644 index 0000000..22bbd88 --- /dev/null +++ b/backend/src/modules/youtube-sync/youtube-sync.service.ts @@ -0,0 +1,147 @@ +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { QuotaService } from '../../shared/quota/quota.service'; +import { YouTubeApiClient } from './youtube-api.client'; +import { hashMetadata } from '../../shared/render-engine/hash'; +import { PrivacyStatus, Prisma, Video } from '@prisma/client'; + +@Injectable() +export class YouTubeSyncService { + constructor( + private readonly prisma: PrismaService, + private readonly quota: QuotaService, + private readonly ytClient: YouTubeApiClient, + ) {} + + async fetchRemote(youtubeVideoId: string, channelId: string) { + if (!(await this.quota.canSpend(1))) throw new Error('Quota exhausted'); + return this.ytClient.getVideoMetadata(youtubeVideoId, channelId); + } + + async pushUpdate( + video: { youtubeVideoId: string; channelId: string }, + meta: { + title: string; + description: string; + tags: string[]; + categoryId?: string; + privacyStatus: PrivacyStatus; + scheduledAt?: Date | null; + defaultLanguage?: string; + defaultAudioLanguage?: string; + selfDeclaredMadeForKids?: boolean; + embeddable?: boolean; + license?: string; + recordingDate?: Date | null; + }, + ctx?: { actionId?: string; actionType?: string }, + ) { + await this.ytClient.updateVideoMetadata(video.youtubeVideoId, meta, video.channelId, ctx); + } + + async detectConflictsForVideos( + videoIds: string[], + ): Promise<{ scanned: number; conflicts: number; quotaExhausted: boolean }> { + if (videoIds.length === 0) return { scanned: 0, conflicts: 0, quotaExhausted: false }; + + const videos = await this.prisma.video.findMany({ where: { id: { in: videoIds } } }); + + // Batch API calls are scoped to a channel's OAuth client, so group first. + const byChannel = new Map(); + for (const v of videos) { + const bucket = byChannel.get(v.channelId); + if (bucket) bucket.push(v); + else byChannel.set(v.channelId, [v]); + } + + let scanned = 0; + let conflicts = 0; + + for (const [channelId, channelVideos] of byChannel) { + for (let i = 0; i < channelVideos.length; i += 50) { + const batch = channelVideos.slice(i, i + 50); + if (!(await this.quota.canSpend(1))) { + return { scanned, conflicts, quotaExhausted: true }; + } + + const items = await this.ytClient.getVideosBatch( + batch.map((v) => v.youtubeVideoId), + channelId, + { actionId: randomUUID(), actionType: 'conflict_detection' }, + ); + const remoteByYtId = new Map(items.map((item) => [item.id, item])); + + for (const video of batch) { + const remote = remoteByYtId.get(video.youtubeVideoId); + if (!remote) continue; // YouTube didn't return this ID; likely deleted, purge job handles it + const conflict = await this.applyConflictDetection(video, remote); + scanned++; + if (conflict) conflicts++; + } + } + } + + return { scanned, conflicts, quotaExhausted: false }; + } + + private async applyConflictDetection(video: Video, remote: any): Promise { + const remoteDescription = remote.snippet?.description ?? ''; + const remotePrivacyStatus = remote.status?.privacyStatus?.toUpperCase() ?? null; + const remoteRecordingDate = remote.recordingDetails?.recordingDate + ? new Date(remote.recordingDetails.recordingDate) + : null; + + const remoteHash = hashMetadata({ + title: remote.snippet?.title ?? '', + description: remoteDescription, + tags: remote.snippet?.tags ?? [], + categoryId: remote.snippet?.categoryId, + privacyStatus: remotePrivacyStatus, + defaultLanguage: remote.snippet?.defaultLanguage, + defaultAudioLanguage: remote.snippet?.defaultAudioLanguage, + selfDeclaredMadeForKids: remote.status?.selfDeclaredMadeForKids, + embeddable: remote.status?.embeddable, + license: remote.status?.license, + recordingDate: remoteRecordingDate, + }); + + const conflict = remoteHash !== video.lastSyncedHash; + + if (conflict) { + const pendingRemoteSnapshot = { + title: remote.snippet?.title ?? '', + tags: remote.snippet?.tags ?? [], + categoryId: remote.snippet?.categoryId ?? null, + privacyStatus: remotePrivacyStatus, + defaultLanguage: remote.snippet?.defaultLanguage ?? null, + defaultAudioLanguage: remote.snippet?.defaultAudioLanguage ?? null, + selfDeclaredMadeForKids: remote.status?.selfDeclaredMadeForKids ?? false, + embeddable: remote.status?.embeddable ?? true, + license: remote.status?.license ?? 'youtube', + recordingDate: remoteRecordingDate ? remoteRecordingDate.toISOString().slice(0, 10) : null, + }; + + await this.prisma.video.update({ + where: { id: video.id }, + data: { + remoteConflict: true, + pendingRemoteSnapshot, + pendingRemoteDescription: remoteDescription, + }, + }); + } else if (video.remoteConflict || video.pendingRemoteSnapshot) { + // Self-heal: remote matches lastSyncedHash again (e.g. the creator reverted their edit). + await this.prisma.video.update({ + where: { id: video.id }, + data: { + remoteConflict: false, + pendingRemoteSnapshot: Prisma.JsonNull, + pendingRemoteDescription: null, + }, + }); + } + + return conflict; + } +} diff --git a/backend/src/queues/processors/bulk-metadata.processor.ts b/backend/src/queues/processors/bulk-metadata.processor.ts new file mode 100644 index 0000000..d5cf63e --- /dev/null +++ b/backend/src/queues/processors/bulk-metadata.processor.ts @@ -0,0 +1,83 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { QUEUES } from '../queues.constants'; + +@Processor(QUEUES.BULK_METADATA) +export class BulkMetadataProcessor extends WorkerHost { + constructor(private readonly prisma: PrismaService) { + super(); + } + + async process(job: Job<{ bulkJobId: string; itemId: string }>) { + const { bulkJobId, itemId } = job.data; + + const item = await this.prisma.bulkJobItem.findUniqueOrThrow({ + where: { id: itemId }, + include: { bulkJob: true, video: true }, + }); + + const bulkJob = item.bulkJob; + const video = item.video; + + const before = { + title: video.title, + tags: video.tags, + privacyStatus: video.privacyStatus, + templateId: video.templateId, + }; + + try { + const after = this.applyAction(before, bulkJob.type, bulkJob.filterSnapshot as any); + + await this.prisma.$transaction([ + this.prisma.video.update({ where: { id: video.id }, data: after }), + this.prisma.bulkJobItem.update({ + where: { id: itemId }, + data: { status: 'done', beforeSnapshot: before, afterSnapshot: after }, + }), + this.prisma.bulkJob.update({ + where: { id: bulkJobId }, + data: { successCount: { increment: 1 } }, + }), + ]); + } catch (err: any) { + await this.prisma.$transaction([ + this.prisma.bulkJobItem.update({ + where: { id: itemId }, + data: { status: 'error', errorMessage: err.message }, + }), + this.prisma.bulkJob.update({ + where: { id: bulkJobId }, + data: { errorCount: { increment: 1 } }, + }), + ]); + } + + // Check if all items are complete + const pending = await this.prisma.bulkJobItem.count({ + where: { bulkJobId, status: 'pending' }, + }); + + if (pending === 0) { + const errorCount = await this.prisma.bulkJobItem.count({ where: { bulkJobId, status: 'error' } }); + await this.prisma.bulkJob.update({ + where: { id: bulkJobId }, + data: { status: errorCount > 0 ? 'FAILED' : 'DONE', completedAt: new Date() }, + }); + } + } + + private applyAction(current: any, type: string, payload: any): any { + const data: Record = {}; + switch (type) { + case 'SET_PRIVACY': data.privacyStatus = payload.payload?.privacyStatus; break; + case 'SET_TEMPLATE': data.templateId = payload.payload?.templateId; break; + case 'ADD_TAGS': data.tags = [...(current.tags ?? []), ...(payload.payload?.tags ?? [])]; break; + case 'REMOVE_TAGS': data.tags = (current.tags ?? []).filter((t: string) => !(payload.payload?.tags ?? []).includes(t)); break; + case 'SEARCH_REPLACE_TITLE': + data.title = current.title?.replace(payload.payload?.search, payload.payload?.replace); break; + } + return data; + } +} diff --git a/backend/src/queues/processors/conflict-detection.processor.ts b/backend/src/queues/processors/conflict-detection.processor.ts new file mode 100644 index 0000000..b2bae88 --- /dev/null +++ b/backend/src/queues/processors/conflict-detection.processor.ts @@ -0,0 +1,77 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import { Logger } from '@nestjs/common'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { YouTubeSyncService } from '../../modules/youtube-sync/youtube-sync.service'; +import { QUEUES } from '../queues.constants'; + +@Processor(QUEUES.CONFLICT_DETECTION, { concurrency: 1 }) +export class ConflictDetectionProcessor extends WorkerHost { + private readonly logger = new Logger(ConflictDetectionProcessor.name); + + constructor( + private readonly prisma: PrismaService, + private readonly ytSync: YouTubeSyncService, + ) { + super(); + } + + async process(_job: Job): Promise<{ + teams: number; + scanned: number; + conflicts: number; + errors: number; + quotaExhausted: boolean; + }> { + const teams = await this.prisma.team.findMany({ + where: { conflictDetectionEnabled: true }, + select: { + id: true, + conflictDetectionBatchSize: true, + conflictDetectionMinAgeDays: true, + }, + }); + + let scanned = 0; + let conflicts = 0; + let errors = 0; + let quotaExhausted = false; + + for (const team of teams) { + const cutoff = new Date(Date.now() - team.conflictDetectionMinAgeDays * 86_400_000); + + const videos = await this.prisma.video.findMany({ + where: { + channel: { teamId: team.id }, + remoteConflict: false, + youtubeDeletedAt: null, + lastSyncedAt: { lt: cutoff, not: null }, + }, + orderBy: { lastSyncedAt: 'asc' }, + take: team.conflictDetectionBatchSize, + select: { id: true }, + }); + + if (videos.length === 0) continue; + + try { + const result = await this.ytSync.detectConflictsForVideos(videos.map((v) => v.id)); + scanned += result.scanned; + conflicts += result.conflicts; + if (result.quotaExhausted) { + quotaExhausted = true; + break; + } + } catch (err: any) { + errors++; + this.logger.warn(`Conflict detection failed for team ${team.id}: ${err?.message ?? err}`); + } + } + + this.logger.log( + `Conflict detection sweep: teams=${teams.length} scanned=${scanned} conflicts=${conflicts} errors=${errors} quotaExhausted=${quotaExhausted}`, + ); + + return { teams: teams.length, scanned, conflicts, errors, quotaExhausted }; + } +} diff --git a/backend/src/queues/processors/import.processor.ts b/backend/src/queues/processors/import.processor.ts new file mode 100644 index 0000000..c3b652b --- /dev/null +++ b/backend/src/queues/processors/import.processor.ts @@ -0,0 +1,16 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import { ImportsService } from '../../modules/imports/imports.service'; +import { QUEUES } from '../queues.constants'; + +@Processor(QUEUES.IMPORT) +export class ImportProcessor extends WorkerHost { + constructor(private readonly importsService: ImportsService) { + super(); + } + + async process(job: Job<{ importJobId: string }>) { + await this.importsService.executeCommit(job.data.importJobId); + return { imported: true }; + } +} diff --git a/backend/src/queues/processors/lint.processor.ts b/backend/src/queues/processors/lint.processor.ts new file mode 100644 index 0000000..2d3003a --- /dev/null +++ b/backend/src/queues/processors/lint.processor.ts @@ -0,0 +1,16 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import { LintingService } from '../../modules/linting/linting.service'; +import { QUEUES } from '../queues.constants'; + +@Processor(QUEUES.LINT) +export class LintProcessor extends WorkerHost { + constructor(private readonly lintingService: LintingService) { + super(); + } + + async process(job: Job<{ videoId: string }>) { + await this.lintingService.lintVideo(job.data.videoId); + return { linted: true }; + } +} diff --git a/backend/src/queues/processors/render.processor.ts b/backend/src/queues/processors/render.processor.ts new file mode 100644 index 0000000..5739aa7 --- /dev/null +++ b/backend/src/queues/processors/render.processor.ts @@ -0,0 +1,36 @@ +import { Processor, WorkerHost, InjectQueue } from '@nestjs/bullmq'; +import { Job, Queue } from 'bullmq'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { VideoRenderService } from '../../shared/render-engine/video-render.service'; +import { QUEUES } from '../queues.constants'; + +@Processor(QUEUES.RENDER) +export class RenderProcessor extends WorkerHost { + constructor( + private readonly prisma: PrismaService, + private readonly videoRender: VideoRenderService, + @InjectQueue(QUEUES.LINT) private readonly lintQueue: Queue, + ) { + super(); + } + + async process(job: Job<{ videoId: string }>) { + const { videoId } = job.data; + + const result = await this.videoRender.renderVideo(videoId); + if (!result) return; + + await this.prisma.video.update({ + where: { id: videoId }, + data: { renderedDescription: result.rendered }, + }); + await this.prisma.videoConfig.update({ + where: { videoId }, + data: { renderHash: result.hash, renderedAt: new Date() }, + }); + + await this.lintQueue.add('lint', { videoId }, { jobId: `lint-${videoId}-after-render` }); + + return { rendered: true }; + } +} diff --git a/backend/src/queues/processors/youtube-sync.processor.ts b/backend/src/queues/processors/youtube-sync.processor.ts new file mode 100644 index 0000000..cab4289 --- /dev/null +++ b/backend/src/queues/processors/youtube-sync.processor.ts @@ -0,0 +1,164 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import { randomUUID } from 'crypto'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { VideoRenderService } from '../../shared/render-engine/video-render.service'; +import { QuotaService } from '../../shared/quota/quota.service'; +import { hashMetadata, hasChanged } from '../../shared/render-engine/hash'; +import { YouTubeSyncService } from '../../modules/youtube-sync/youtube-sync.service'; +import { QUEUES } from '../queues.constants'; + +@Processor(QUEUES.YOUTUBE_SYNC, { concurrency: 1, limiter: { max: 5, duration: 10_000 } }) +export class YouTubeSyncProcessor extends WorkerHost { + constructor( + private readonly prisma: PrismaService, + private readonly videoRender: VideoRenderService, + private readonly quota: QuotaService, + private readonly ytSync: YouTubeSyncService, + ) { + super(); + } + + async process(job: Job<{ videoId: string; bulkJobItemId?: string }>) { + const { videoId, bulkJobItemId } = job.data; + + const [video, renderResult] = await Promise.all([ + this.prisma.video.findUniqueOrThrow({ + where: { id: videoId }, + select: { + channelId: true, + youtubeVideoId: true, + lastSyncedHash: true, + renderedDescription: true, + youtubeSnapshot: true, + title: true, + tags: true, + categoryId: true, + privacyStatus: true, + scheduledAt: true, + defaultLanguage: true, + defaultAudioLanguage: true, + selfDeclaredMadeForKids: true, + embeddable: true, + license: true, + recordingDate: true, + }, + }), + this.videoRender.renderVideo(videoId), + ]); + + if (!renderResult) { + if (bulkJobItemId) await this.finishBulkItem(bulkJobItemId, 'error', null, null, 'Render failed'); + return; + } + + const newHash = hashMetadata({ + title: video.title, + description: renderResult.rendered, + tags: video.tags, + categoryId: video.categoryId, + privacyStatus: video.privacyStatus, + defaultLanguage: video.defaultLanguage, + defaultAudioLanguage: video.defaultAudioLanguage, + selfDeclaredMadeForKids: video.selfDeclaredMadeForKids, + embeddable: video.embeddable, + license: video.license, + recordingDate: video.recordingDate, + }); + + if (!hasChanged(video.lastSyncedHash, newHash)) { + // Keep renderedDescription in sync with the fresh render so computePendingChanges + // (which hashes renderedDescription) agrees and clears the "push pending" indicator. + if (video.renderedDescription !== renderResult.rendered) { + await this.prisma.video.update({ + where: { id: videoId }, + data: { renderedDescription: renderResult.rendered }, + }); + } + if (bulkJobItemId) await this.finishBulkItem(bulkJobItemId, 'done', video.youtubeSnapshot as any, video.youtubeSnapshot as any); + return { skipped: true, reason: 'no-change' }; + } + + if (!(await this.quota.canSpend(50))) { + await job.moveToDelayed(Date.now() + this.quota.msUntilQuotaReset()); + return { deferred: true }; + } + + const ctx = { actionId: randomUUID(), actionType: 'video_sync' }; + + try { + await this.ytSync.pushUpdate(video, { + title: video.title, + description: renderResult.rendered, + tags: video.tags, + categoryId: video.categoryId ?? undefined, + privacyStatus: video.privacyStatus, + scheduledAt: video.scheduledAt, + defaultLanguage: video.defaultLanguage ?? undefined, + defaultAudioLanguage: video.defaultAudioLanguage ?? undefined, + selfDeclaredMadeForKids: video.selfDeclaredMadeForKids, + embeddable: video.embeddable, + license: video.license, + recordingDate: video.recordingDate, + }, ctx); + } catch (err: any) { + if (bulkJobItemId) await this.finishBulkItem(bulkJobItemId, 'error', null, null, err?.message ?? 'Push failed'); + throw err; + } + + const youtubeSnapshot = { + title: video.title, + tags: video.tags, + categoryId: video.categoryId ?? null, + privacyStatus: String(video.privacyStatus), + defaultLanguage: video.defaultLanguage ?? null, + defaultAudioLanguage: video.defaultAudioLanguage ?? null, + selfDeclaredMadeForKids: video.selfDeclaredMadeForKids, + embeddable: video.embeddable, + license: video.license, + recordingDate: video.recordingDate ? video.recordingDate.toISOString().slice(0, 10) : null, + }; + + await this.prisma.video.update({ + where: { id: videoId }, + data: { + lastSyncedHash: newHash, lastSyncedAt: new Date(), remoteConflict: false, + renderedDescription: renderResult.rendered, youtubeSnapshot, + }, + }); + + if (bulkJobItemId) await this.finishBulkItem(bulkJobItemId, 'done', video.youtubeSnapshot as any, youtubeSnapshot); + + return { synced: true }; + } + + private async finishBulkItem( + itemId: string, + status: 'done' | 'error', + beforeSnapshot: Record | null, + afterSnapshot: Record | null, + errorMessage?: string, + ) { + const item = await this.prisma.bulkJobItem.findUniqueOrThrow({ where: { id: itemId }, select: { bulkJobId: true } }); + + await this.prisma.bulkJobItem.update({ + where: { id: itemId }, + data: { status, beforeSnapshot: beforeSnapshot ?? undefined, afterSnapshot: afterSnapshot ?? undefined, errorMessage: errorMessage ?? null }, + }); + + if (status === 'done') { + await this.prisma.bulkJob.update({ where: { id: item.bulkJobId }, data: { successCount: { increment: 1 } } }); + } else { + await this.prisma.bulkJob.update({ where: { id: item.bulkJobId }, data: { errorCount: { increment: 1 } } }); + } + + const remaining = await this.prisma.bulkJobItem.count({ where: { bulkJobId: item.bulkJobId, status: 'pending' } }); + if (remaining === 0) { + const errorCount = await this.prisma.bulkJobItem.count({ where: { bulkJobId: item.bulkJobId, status: 'error' } }); + await this.prisma.bulkJob.update({ + where: { id: item.bulkJobId }, + data: { status: errorCount > 0 ? 'FAILED' : 'DONE', completedAt: new Date() }, + }); + } + } +} diff --git a/backend/src/queues/queues.constants.ts b/backend/src/queues/queues.constants.ts new file mode 100644 index 0000000..8a8f340 --- /dev/null +++ b/backend/src/queues/queues.constants.ts @@ -0,0 +1,8 @@ +export const QUEUES = { + YOUTUBE_SYNC: 'youtube-sync', + BULK_METADATA: 'bulk-metadata', + RENDER: 'render', + LINT: 'lint', + IMPORT: 'import', + CONFLICT_DETECTION: 'conflict-detection', +} as const; diff --git a/backend/src/queues/schedulers/conflict-detection.scheduler.ts b/backend/src/queues/schedulers/conflict-detection.scheduler.ts new file mode 100644 index 0000000..e1969c0 --- /dev/null +++ b/backend/src/queues/schedulers/conflict-detection.scheduler.ts @@ -0,0 +1,40 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { ConfigService } from '@nestjs/config'; +import { QUEUES } from '../queues.constants'; + +const JOB_NAME = 'conflict-detection-sweep'; + +@Injectable() +export class ConflictDetectionScheduler implements OnModuleInit { + private readonly logger = new Logger(ConflictDetectionScheduler.name); + + constructor( + @InjectQueue(QUEUES.CONFLICT_DETECTION) private readonly queue: Queue, + private readonly config: ConfigService, + ) {} + + async onModuleInit() { + const enabled = this.config.get('CONFLICT_DETECTION_ENABLED', 'false') === 'true'; + const pattern = this.config.get('CONFLICT_DETECTION_CRON', '0 3 * * *'); + + // Wipe stale repeatables so a config change (or disable) takes effect on next boot. + const repeatables = await this.queue.getRepeatableJobs(); + for (const r of repeatables) { + if (r.name === JOB_NAME) await this.queue.removeRepeatableByKey(r.key); + } + + if (!enabled) { + this.logger.log('Conflict detection scheduler disabled (CONFLICT_DETECTION_ENABLED != true)'); + return; + } + + await this.queue.add( + JOB_NAME, + {}, + { repeat: { pattern }, removeOnComplete: 10, removeOnFail: 10 }, + ); + this.logger.log(`Conflict detection scheduler active — cron "${pattern}"`); + } +} diff --git a/backend/src/shared/audit/audit.module.ts b/backend/src/shared/audit/audit.module.ts new file mode 100644 index 0000000..b8b73da --- /dev/null +++ b/backend/src/shared/audit/audit.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { AuditService } from './audit.service'; + +@Module({ + providers: [AuditService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/backend/src/shared/audit/audit.service.ts b/backend/src/shared/audit/audit.service.ts new file mode 100644 index 0000000..560414b --- /dev/null +++ b/backend/src/shared/audit/audit.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class AuditService { + constructor(private readonly prisma: PrismaService) {} + + async log( + actorId: string, + entityType: string, + entityId: string, + action: string, + before?: object | null, + after?: object | null, + requestId?: string, + ): Promise { + await this.prisma.auditLog.create({ + data: { + actorId, + entityType, + entityId, + action, + beforeJson: before ?? undefined, + afterJson: after ?? undefined, + requestId, + }, + }); + } +} diff --git a/backend/src/shared/prisma/prisma.module.ts b/backend/src/shared/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/backend/src/shared/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/backend/src/shared/prisma/prisma.service.ts b/backend/src/shared/prisma/prisma.service.ts new file mode 100644 index 0000000..623d5e0 --- /dev/null +++ b/backend/src/shared/prisma/prisma.service.ts @@ -0,0 +1,13 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/backend/src/shared/quota/quota.module.ts b/backend/src/shared/quota/quota.module.ts new file mode 100644 index 0000000..387bc73 --- /dev/null +++ b/backend/src/shared/quota/quota.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { QuotaService } from './quota.service'; + +@Module({ + providers: [QuotaService], + exports: [QuotaService], +}) +export class QuotaModule {} diff --git a/backend/src/shared/quota/quota.service.ts b/backend/src/shared/quota/quota.service.ts new file mode 100644 index 0000000..8abcdb8 --- /dev/null +++ b/backend/src/shared/quota/quota.service.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class QuotaService { + private readonly DAILY_LIMIT = 9_000; + + constructor(private readonly prisma: PrismaService) {} + + async canSpend(units: number, teamId?: string): Promise { + const used = teamId ? await this.getTodayUsageForTeam(teamId) : await this.getTodayUsage(); + return used + units <= this.DAILY_LIMIT; + } + + async spend( + units: number, + operation: string, + channelId?: string, + videoId?: string, + bulkJobId?: string, + entityId?: string, + actionId?: string, + actionType?: string, + ): Promise { + await this.prisma.quotaLog.create({ + data: { + units, + operation, + channelId: channelId ?? null, + videoId: videoId ?? null, + bulkJobId: bulkJobId ?? null, + entityId: entityId ?? null, + actionId: actionId ?? null, + actionType: actionType ?? null, + datePt: this.getTodayPT(), + }, + }); + } + + async getTodayUsageForTeam(teamId: string): Promise { + // Collect all channel IDs belonging to this team, then sum quota logs for those channels. + // Using channelId IN (...) avoids the Prisma INNER JOIN that silently drops null-channelId rows. + const channels = await this.prisma.channel.findMany({ + where: { teamId }, + select: { id: true }, + }); + const channelIds = channels.map((c) => c.id); + + if (channelIds.length === 0) return 0; + + const result = await this.prisma.quotaLog.aggregate({ + _sum: { units: true }, + where: { + datePt: this.getTodayPT(), + channelId: { in: channelIds }, + }, + }); + return result._sum.units ?? 0; + } + + async getTodayUsage(): Promise { + const result = await this.prisma.quotaLog.aggregate({ + _sum: { units: true }, + where: { datePt: this.getTodayPT() }, + }); + return result._sum.units ?? 0; + } + + async getRemainingToday(): Promise { + return this.DAILY_LIMIT - (await this.getTodayUsage()); + } + + getLimit(): number { + return this.DAILY_LIMIT; + } + + msUntilQuotaReset(): number { + const now = new Date(); + // YouTube quota resets at midnight Pacific Time (UTC-7 daylight / UTC-8 standard) + const PT_OFFSET_MS = 7 * 60 * 60 * 1000; // PDT (summer) + const nowPT = new Date(now.getTime() - PT_OFFSET_MS); + const tomorrowMidnightPT = new Date(nowPT); + tomorrowMidnightPT.setUTCHours(24, 0, 0, 0); + return tomorrowMidnightPT.getTime() - nowPT.getTime(); + } + + private getTodayPT(): Date { + const now = new Date(); + const PT_OFFSET_MS = 7 * 60 * 60 * 1000; // PDT (summer) + const nowPT = new Date(now.getTime() - PT_OFFSET_MS); + return new Date(Date.UTC(nowPT.getUTCFullYear(), nowPT.getUTCMonth(), nowPT.getUTCDate())); + } +} diff --git a/backend/src/shared/render-engine/hash.ts b/backend/src/shared/render-engine/hash.ts new file mode 100644 index 0000000..24a2212 --- /dev/null +++ b/backend/src/shared/render-engine/hash.ts @@ -0,0 +1,21 @@ +import { createHash } from 'crypto'; + +export function hashMetadata(data: { + title: string; + description: string; + tags: string[]; + categoryId?: string | null; + privacyStatus?: string | null; + defaultLanguage?: string | null; + defaultAudioLanguage?: string | null; + selfDeclaredMadeForKids?: boolean; + embeddable?: boolean; + license?: string; + recordingDate?: Date | string | null; +}): string { + return createHash('sha256').update(JSON.stringify(data)).digest('hex'); +} + +export function hasChanged(lastSyncedHash: string | null | undefined, newHash: string): boolean { + return lastSyncedHash !== newHash; +} diff --git a/backend/src/shared/render-engine/pending-changes.ts b/backend/src/shared/render-engine/pending-changes.ts new file mode 100644 index 0000000..a4fde99 --- /dev/null +++ b/backend/src/shared/render-engine/pending-changes.ts @@ -0,0 +1,50 @@ +import { hashMetadata } from './hash'; + +export function computePendingChanges(v: { + title: string; + renderedDescription?: string | null; + youtubeDescription?: string | null; + tags: string[]; + categoryId?: string | null; + privacyStatus?: string | null; + defaultLanguage?: string | null; + defaultAudioLanguage?: string | null; + selfDeclaredMadeForKids?: boolean | null; + embeddable?: boolean | null; + license?: string | null; + recordingDate?: Date | null; + lastSyncedHash?: string | null; +}): boolean { + const sharedFields = { + tags: v.tags as string[], + categoryId: v.categoryId ?? null, + privacyStatus: v.privacyStatus ?? null, + defaultLanguage: v.defaultLanguage ?? null, + defaultAudioLanguage: v.defaultAudioLanguage ?? null, + selfDeclaredMadeForKids: v.selfDeclaredMadeForKids ?? false, + embeddable: v.embeddable ?? true, + license: v.license ?? 'youtube', + }; + if (v.lastSyncedHash !== null && v.lastSyncedHash !== undefined) { + const currentHash = hashMetadata({ + title: v.title, + description: v.renderedDescription ?? v.youtubeDescription ?? '', + ...sharedFields, + recordingDate: v.recordingDate ?? null, + }); + return v.lastSyncedHash !== currentHash; + } + const syntheticBaselineHash = hashMetadata({ + title: v.title, + description: v.youtubeDescription ?? '', + ...sharedFields, + recordingDate: null, + }); + const currentHash = hashMetadata({ + title: v.title, + description: v.renderedDescription ?? v.youtubeDescription ?? '', + ...sharedFields, + recordingDate: v.recordingDate ?? null, + }); + return syntheticBaselineHash !== currentHash; +} diff --git a/backend/src/shared/render-engine/render-engine.module.ts b/backend/src/shared/render-engine/render-engine.module.ts new file mode 100644 index 0000000..c930472 --- /dev/null +++ b/backend/src/shared/render-engine/render-engine.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { RenderEngineService } from './render-engine.service'; +import { VideoRenderService } from './video-render.service'; + +@Module({ + providers: [RenderEngineService, VideoRenderService], + exports: [RenderEngineService, VideoRenderService], +}) +export class RenderEngineModule {} diff --git a/backend/src/shared/render-engine/render-engine.service.ts b/backend/src/shared/render-engine/render-engine.service.ts new file mode 100644 index 0000000..d2e70a0 --- /dev/null +++ b/backend/src/shared/render-engine/render-engine.service.ts @@ -0,0 +1,332 @@ +import { Injectable } from '@nestjs/common'; +import { BlockType } from '@prisma/client'; +import { hashMetadata } from './hash'; +import { SYSTEM_VARIABLE_TOKENS } from '../system-variables/system-variables.registry'; + +interface RenderBlock { + id: string; + type: BlockType; + content: string; + active: boolean; + compact: boolean; + campaignId?: string | null; + condition?: any | null; +} + +interface RenderCollaborator { + id: string; + name: string; + youtubeLink?: string | null; + twitchLink?: string | null; + instagramLink?: string | null; + tiktokLink?: string | null; + twitterLink?: string | null; + blueskyLink?: string | null; + discordHandle?: string | null; + aliases?: string[]; + notes?: string | null; +} + +export interface RenderVideoData { + title: string; + tags: string[]; + categoryId?: string | null; + scheduledAt?: Date | string | null; + recordingDate?: Date | string | null; + gameTitle?: string | null; + language?: string | null; + playlists?: { title: string; youtubePlaylistId: string }[]; +} + +interface RenderConfig { + blockOrder: string[]; + blockOverrides: Record; + /** Video-level values — override team variables */ + variableValues: Record; + /** Team-level defaults — lowest priority */ + teamVariables?: Record; + collaboratorIds: string[]; + /** Team-level default date format. Falls back to YYYY-MM-DD if absent. */ + dateFormat?: string | null; +} + +export interface RenderInput { + video: RenderVideoData; + config: RenderConfig; + blocks: RenderBlock[]; + collaborators: RenderCollaborator[]; + /** Campaign blocks whose campaign date window is currently active — appended after blockOrder */ + activeCampaignBlocks?: RenderBlock[]; +} + +export interface RenderResult { + rendered: string; + hash: string; +} + +// ─── Date formatting ────────────────────────────────────────────────────────── + +const MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; +const MONTH_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD'; + +/** + * Format a date using a format string. + * Tokens: YYYY, YY, MMMM (full month), MMM (short month), MM (zero-padded), M, + * DD (zero-padded day), D + */ +function applyDateFormat(d: Date | string | null | undefined, format: string): string { + if (!d) return ''; + const date = typeof d === 'string' ? new Date(d) : d; + const day = date.getUTCDate(); + const month = date.getUTCMonth(); // 0-indexed + const year = date.getUTCFullYear(); + + return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YY/g, (token) => { + switch (token) { + case 'MMMM': return MONTH_NAMES[month]; + case 'MMM': return MONTH_SHORT[month]; + case 'MM': return String(month + 1).padStart(2, '0'); + case 'M': return String(month + 1); + case 'DD': return String(day).padStart(2, '0'); + case 'D': return String(day); + case 'YYYY': return String(year); + case 'YY': return String(year).slice(-2); + default: return token; + } + }); +} + +function formatDate( + d: Date | string | null | undefined, + tokenFormat: string | undefined, + teamFormat: string | null | undefined, +): string { + const fmt = tokenFormat ?? teamFormat ?? DEFAULT_DATE_FORMAT; + return applyDateFormat(d, fmt); +} + +// ─── Resolvers ──────────────────────────────────────────────────────────────── + +/** Maps collab.* tokens to a function that extracts the value from a collaborator */ +const COLLAB_RESOLVERS: Record string> = { + 'collab.name': (c) => c.name, + 'collab.youtube': (c) => c.youtubeLink ?? '', + 'collab.twitch': (c) => c.twitchLink ?? '', + 'collab.instagram': (c) => c.instagramLink ?? '', + 'collab.tiktok': (c) => c.tiktokLink ?? '', + 'collab.twitter': (c) => c.twitterLink ?? '', + 'collab.bluesky': (c) => c.blueskyLink ?? '', + 'collab.discord': (c) => c.discordHandle ?? '', + 'collab.aliases': (c) => (c.aliases ?? []).join(', '), + 'collab.notes': (c) => c.notes ?? '', +}; + +const YOUTUBE_CATEGORY_NAMES: Record = { + '1': 'Film & Animation', '2': 'Autos & Vehicles', '10': 'Music', + '15': 'Pets & Animals', '17': 'Sports', '18': 'Short Movies', + '19': 'Travel & Events', '20': 'Gaming', '21': 'Videoblogging', + '22': 'People & Blogs', '23': 'Comedy', '24': 'Entertainment', + '25': 'News & Politics', '26': 'Howto & Style', '27': 'Education', + '28': 'Science & Technology', '29': 'Nonprofits & Activism', +}; + +type VideoResolver = + | { kind: 'simple'; fn: (v: RenderVideoData) => string } + | { kind: 'date'; fn: (v: RenderVideoData) => Date | string | null | undefined }; + +/** Maps video.* tokens. Date tokens carry a 'date' kind so format strings are applied. */ +const VIDEO_RESOLVERS: Record = { + 'video.title': { kind: 'simple', fn: (v) => v.title }, + 'video.tags': { kind: 'simple', fn: (v) => (v.tags ?? []).join(', ') }, + 'video.category': { kind: 'simple', fn: (v) => v.categoryId ? (YOUTUBE_CATEGORY_NAMES[v.categoryId] ?? v.categoryId) : '' }, + 'video.scheduledAt': { kind: 'date', fn: (v) => v.scheduledAt }, + 'video.recordingDate': { kind: 'date', fn: (v) => v.recordingDate }, + 'video.gameTitle': { kind: 'simple', fn: (v) => v.gameTitle ?? '' }, + 'video.language': { kind: 'simple', fn: (v) => v.language ?? '' }, + 'video.playlists': { kind: 'simple', fn: (v) => (v.playlists ?? []).map((p) => p.title).join(', ') }, + 'video.playlistLinks': { kind: 'simple', fn: (v) => (v.playlists ?? []).map((p) => `https://www.youtube.com/playlist?list=${p.youtubePlaylistId}`).join(', ') }, +}; + +// Regex that captures: key (group 1) and optional |format suffix (group 2) +// e.g. {video.scheduledAt|DD.MM.YYYY} → key='video.scheduledAt', fmt='DD.MM.YYYY' +const TOKEN_RE = /\{([a-zA-Z_@][a-zA-Z0-9_.]*)(?:\|([^}]*))?\}/g; + +@Injectable() +export class RenderEngineService { + render(input: RenderInput): RenderResult { + const { video, config, blocks, collaborators } = input; + const blockMap = new Map(blocks.map((b) => [b.id, b])); + + // Team vars are the baseline; video-level vars override + const effectiveVars: Record = { + ...(config.teamVariables ?? {}), + ...config.variableValues, + }; + + const segments: { text: string; compact: boolean }[] = []; + + for (const blockId of config.blockOrder) { + // Free-text entry: content lives entirely in blockOverrides, no DB block needed + if (blockId.startsWith('freetext:')) { + const override = config.blockOverrides[blockId] ?? {}; + if (override.active === false) continue; + if (override.content) { + segments.push({ + text: this.resolveCollaborators( + this.resolveVideoVars( + this.resolveVariables(override.content, effectiveVars), + video, + config.dateFormat, + ), + collaborators, + config.collaboratorIds, + ), + compact: override.compact ?? false, + }); + } + continue; + } + + const block = blockMap.get(blockId); + if (!block) continue; + + const override = config.blockOverrides[blockId] ?? {}; + const isActive = override.active !== undefined ? override.active : block.active; + if (!isActive) continue; + + const content = override.content !== undefined ? override.content : block.content; + + // STATIC blocks are output verbatim — no token substitution of any kind + if (block.type === BlockType.STATIC) { + segments.push({ text: content, compact: override.compact ?? block.compact }); + continue; + } + + let resolved = this.resolveVariables(content, effectiveVars); + resolved = this.resolveVideoVars(resolved, video, config.dateFormat); + + if (block.type === BlockType.COLLABORATOR) { + const compact = override.compact ?? block.compact; + segments.push({ + text: this.expandCollaboratorBlock(resolved, collaborators, config.collaboratorIds, compact), + compact, + }); + continue; + } + + if (block.type === BlockType.CONDITIONAL) { + const activeCollabCount = collaborators.filter((c) => config.collaboratorIds.includes(c.id)).length; + if (!this.evaluateCondition(block.condition, effectiveVars, activeCollabCount)) continue; + } + + resolved = this.resolveCollaborators(resolved, collaborators, config.collaboratorIds); + segments.push({ text: resolved, compact: override.compact ?? block.compact }); + } + + // Append active campaign blocks not already handled in blockOrder + for (const cb of (input.activeCampaignBlocks ?? [])) { + if (config.blockOrder.includes(cb.id)) continue; + if (!cb.active) continue; + let text = this.resolveVariables(cb.content, effectiveVars); + text = this.resolveVideoVars(text, video, config.dateFormat); + text = this.resolveCollaborators(text, collaborators, config.collaboratorIds); + segments.push({ text, compact: false }); + } + + const rendered = segments.reduce((acc, seg, i) => { + if (i === 0) return seg.text; + return acc + (seg.compact ? '\n' : '\n\n') + seg.text; + }, ''); + + const hash = hashMetadata({ + title: video.title, + description: rendered, + tags: video.tags, + categoryId: video.categoryId, + }); + + return { rendered, hash }; + } + + private resolveVariables(content: string, values: Record): string { + return content.replace(TOKEN_RE, (match, key) => { + // System variables are resolved by dedicated resolvers — skip here + if (SYSTEM_VARIABLE_TOKENS.has(key)) return match; + return values[key] !== undefined ? values[key] : match; + }); + } + + private resolveVideoVars(content: string, video: RenderVideoData, teamDateFormat?: string | null): string { + return content.replace(TOKEN_RE, (match, key, tokenFmt) => { + const resolver = VIDEO_RESOLVERS[key]; + if (!resolver) return match; + if (resolver.kind === 'date') { + return formatDate(resolver.fn(video), tokenFmt, teamDateFormat); + } + return resolver.fn(video); + }); + } + + private resolveCollaborators( + content: string, + collaborators: RenderCollaborator[], + collaboratorIds: string[], + ): string { + const primary = collaborators.find((c) => collaboratorIds.includes(c.id)); + if (!primary) return content; + return this.applyCollabTokens(content, primary); + } + + private expandCollaboratorBlock( + template: string, + collaborators: RenderCollaborator[], + collaboratorIds: string[], + compact = false, + ): string { + const active = collaborators.filter((c) => collaboratorIds.includes(c.id)); + if (active.length === 0) return ''; + return active.map((c) => this.applyCollabTokens(template, c)).join(compact ? '\n' : '\n\n'); + } + + private applyCollabTokens(content: string, c: RenderCollaborator): string { + return content.replace(TOKEN_RE, (match, key) => { + const resolver = COLLAB_RESOLVERS[key]; + return resolver ? resolver(c) : match; + }); + } + + private evaluateCondition( + condition: any, + effectiveVars: Record, + collabCount: number, + ): boolean { + const { combinator = 'and', rules } = condition; + if (!Array.isArray(rules) || rules.length === 0) return true; + + const results = (rules as any[]).map((rule) => { + switch (rule.type) { + case 'variable_filled': return !!(effectiveVars[rule.variable]); + case 'variable_empty': return !(effectiveVars[rule.variable]); + case 'collab_count': { + const n = Number(rule.value ?? 0); + switch (rule.operator) { + case 'eq': return collabCount === n; + case 'gt': return collabCount > n; + case 'lt': return collabCount < n; + case 'gte': return collabCount >= n; + case 'lte': return collabCount <= n; + default: return true; + } + } + default: return true; + } + }); + + return combinator === 'or' ? results.some(Boolean) : results.every(Boolean); + } +} diff --git a/backend/src/shared/render-engine/video-render.service.ts b/backend/src/shared/render-engine/video-render.service.ts new file mode 100644 index 0000000..fb968a9 --- /dev/null +++ b/backend/src/shared/render-engine/video-render.service.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { RenderEngineService, RenderResult } from './render-engine.service'; + +@Injectable() +export class VideoRenderService { + constructor( + private readonly prisma: PrismaService, + private readonly renderEngine: RenderEngineService, + ) {} + + /** Fetches all data needed for rendering and returns the render result. Returns null if the video has no config. */ + async renderVideo(videoId: string): Promise { + const video = await this.prisma.video.findUniqueOrThrow({ + where: { id: videoId }, + include: { + config: true, + channel: { select: { teamId: true } }, + playlists: { include: { playlist: { select: { title: true, youtubePlaylistId: true } } } }, + }, + }); + + if (!video.config) return null; + + const blockIds = video.config.blockOrder as string[]; + const collaboratorIds = (video.collaboratorIds as string[]) ?? []; + const teamId = video.channel.teamId; + const now = new Date(); + + const [blocks, teamVarRows, teamSettings, activeCampaignBlocks, collaborators] = await Promise.all([ + this.prisma.descriptionBlock.findMany({ where: { id: { in: blockIds } } }), + this.prisma.teamVariable.findMany({ where: { teamId } }), + this.prisma.team.findUnique({ where: { id: teamId }, select: { dateFormat: true } }), + this.prisma.descriptionBlock.findMany({ + where: { + teamId, + type: 'CAMPAIGN', + active: true, + campaign: { startAt: { lte: now }, OR: [{ endAt: null }, { endAt: { gte: now } }] }, + }, + }), + this.prisma.collaborator.findMany({ where: { id: { in: collaboratorIds } } }), + ]); + + const teamVariables = Object.fromEntries(teamVarRows.map((v) => [v.name, v.value])); + + return this.renderEngine.render({ + video: { + title: video.title, + tags: video.tags, + categoryId: video.categoryId, + scheduledAt: video.scheduledAt, + recordingDate: video.recordingDate, + gameTitle: video.gameTitle, + language: video.defaultLanguage, + playlists: video.playlists.map((vp) => vp.playlist), + }, + config: { + blockOrder: blockIds, + blockOverrides: (video.config.blockOverrides as any) ?? {}, + variableValues: (video.config.variableValues as any) ?? {}, + teamVariables, + dateFormat: teamSettings?.dateFormat, + collaboratorIds, + }, + blocks, + collaborators, + activeCampaignBlocks, + }); + } +} diff --git a/backend/src/shared/system-variables/system-variables.controller.ts b/backend/src/shared/system-variables/system-variables.controller.ts new file mode 100644 index 0000000..e20f2c3 --- /dev/null +++ b/backend/src/shared/system-variables/system-variables.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { SYSTEM_VARIABLES } from './system-variables.registry'; + +@ApiTags('system-variables') +@Controller('system-variables') +export class SystemVariablesController { + @Get() + @ApiOperation({ summary: 'List all system-defined template variables with full metadata' }) + findAll() { + return SYSTEM_VARIABLES; + } +} diff --git a/backend/src/shared/system-variables/system-variables.module.ts b/backend/src/shared/system-variables/system-variables.module.ts new file mode 100644 index 0000000..37cabde --- /dev/null +++ b/backend/src/shared/system-variables/system-variables.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { SystemVariablesController } from './system-variables.controller'; + +@Module({ + controllers: [SystemVariablesController], +}) +export class SystemVariablesModule {} diff --git a/backend/src/shared/system-variables/system-variables.registry.ts b/backend/src/shared/system-variables/system-variables.registry.ts new file mode 100644 index 0000000..a75cea7 --- /dev/null +++ b/backend/src/shared/system-variables/system-variables.registry.ts @@ -0,0 +1,177 @@ +export type SystemVariableGroup = 'collaborator' | 'video'; + +export interface SystemVariable { + /** Token without braces — dot-namespaced, e.g. "collab.name" */ + token: string; + /** Ready-to-paste placeholder, e.g. "{collab.name}" */ + placeholder: string; + /** Short human label shown in the UI */ + label: string; + /** One-sentence explanation of what this resolves to at render time */ + description: string; + /** Realistic example value */ + example: string; + /** Logical grouping for UI display */ + group: SystemVariableGroup; +} + +export const SYSTEM_VARIABLES: SystemVariable[] = [ + // ─── Collaborator variables ──────────────────────────────────────────────── + { + token: 'collab.name', + placeholder: '{collab.name}', + label: 'Collaborator Name', + description: 'Full display name of the collaborator as stored in the system.', + example: 'Jane Doe', + group: 'collaborator', + }, + { + token: 'collab.youtube', + placeholder: '{collab.youtube}', + label: 'YouTube Link', + description: "Full URL to the collaborator's YouTube channel. Empty string if not set.", + example: 'https://www.youtube.com/@janedoe', + group: 'collaborator', + }, + { + token: 'collab.twitch', + placeholder: '{collab.twitch}', + label: 'Twitch Link', + description: "Full URL to the collaborator's Twitch channel. Empty string if not set.", + example: 'https://twitch.tv/janedoe', + group: 'collaborator', + }, + { + token: 'collab.instagram', + placeholder: '{collab.instagram}', + label: 'Instagram Link', + description: "Full URL to the collaborator's Instagram profile. Empty string if not set.", + example: 'https://instagram.com/janedoe', + group: 'collaborator', + }, + { + token: 'collab.tiktok', + placeholder: '{collab.tiktok}', + label: 'TikTok Link', + description: "Full URL to the collaborator's TikTok profile. Empty string if not set.", + example: 'https://tiktok.com/@janedoe', + group: 'collaborator', + }, + { + token: 'collab.twitter', + placeholder: '{collab.twitter}', + label: 'Twitter / X Link', + description: "Full URL to the collaborator's Twitter/X profile. Empty string if not set.", + example: 'https://x.com/janedoe', + group: 'collaborator', + }, + { + token: 'collab.bluesky', + placeholder: '{collab.bluesky}', + label: 'Bluesky Link', + description: "Full URL to the collaborator's Bluesky profile. Empty string if not set.", + example: 'https://bsky.app/profile/janedoe', + group: 'collaborator', + }, + { + token: 'collab.discord', + placeholder: '{collab.discord}', + label: 'Discord Handle', + description: "Collaborator's Discord handle. Empty string if not set.", + example: 'janedoe#1234', + group: 'collaborator', + }, + { + token: 'collab.aliases', + placeholder: '{collab.aliases}', + label: 'Collaborator Aliases', + description: 'Comma-separated list of alternative names or usernames for the collaborator.', + example: 'jdoe, jane_plays', + group: 'collaborator', + }, + { + token: 'collab.notes', + placeholder: '{collab.notes}', + label: 'Collaborator Notes', + description: 'Free-form notes about the collaborator. Empty string if not set.', + example: 'Main guest on FPS episodes', + group: 'collaborator', + }, + + // ─── Video variables ─────────────────────────────────────────────────────── + { + token: 'video.title', + placeholder: '{video.title}', + label: 'Video Title', + description: 'The title of the video as set in the studio.', + example: 'My Awesome Let\'s Play #42', + group: 'video', + }, + { + token: 'video.tags', + placeholder: '{video.tags}', + label: 'Tags', + description: 'Comma-separated list of all video tags.', + example: 'gaming, fps, tutorial', + group: 'video', + }, + { + token: 'video.category', + placeholder: '{video.category}', + label: 'Category', + description: 'YouTube category name derived from the category ID.', + example: 'Gaming', + group: 'video', + }, + { + token: 'video.scheduledAt', + placeholder: '{video.scheduledAt}', + label: 'Scheduled Date', + description: 'Scheduled publish date formatted as YYYY-MM-DD. Empty if not scheduled.', + example: '2026-06-01', + group: 'video', + }, + { + token: 'video.recordingDate', + placeholder: '{video.recordingDate}', + label: 'Recording Date', + description: 'Date the video was recorded, formatted as YYYY-MM-DD. Empty if not set.', + example: '2026-05-20', + group: 'video', + }, + { + token: 'video.gameTitle', + placeholder: '{video.gameTitle}', + label: 'Game Title', + description: 'Title of the game being played. Empty if not set.', + example: 'Minecraft', + group: 'video', + }, + { + token: 'video.language', + placeholder: '{video.language}', + label: 'Video Language', + description: 'BCP-47 language code of the video (e.g. "en", "de"). Empty if not set.', + example: 'en', + group: 'video', + }, + { + token: 'video.playlists', + placeholder: '{video.playlists}', + label: 'Playlist Names', + description: 'Comma-separated names of all playlists this video belongs to.', + example: 'Gaming Highlights, Best Of 2026', + group: 'video', + }, + { + token: 'video.playlistLinks', + placeholder: '{video.playlistLinks}', + label: 'Playlist Links', + description: 'Comma-separated YouTube playlist URLs for all playlists this video belongs to.', + example: 'https://www.youtube.com/playlist?list=PLabc123', + group: 'video', + }, +]; + +/** Set of tokens for fast membership checks */ +export const SYSTEM_VARIABLE_TOKENS = new Set(SYSTEM_VARIABLES.map((v) => v.token)); diff --git a/backend/src/worker.module.ts b/backend/src/worker.module.ts new file mode 100644 index 0000000..5b8ed16 --- /dev/null +++ b/backend/src/worker.module.ts @@ -0,0 +1,57 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { BullModule } from '@nestjs/bullmq'; +import { PrismaModule } from './shared/prisma/prisma.module'; +import { RenderEngineModule } from './shared/render-engine/render-engine.module'; +import { QuotaModule } from './shared/quota/quota.module'; +import { AuditModule } from './shared/audit/audit.module'; +import { YouTubeSyncModule } from './modules/youtube-sync/youtube-sync.module'; +import { BulkJobsModule } from './modules/bulk-jobs/bulk-jobs.module'; +import { LintingModule } from './modules/linting/linting.module'; +import { ImportsModule } from './modules/imports/imports.module'; +import { QUEUES } from './queues/queues.constants'; +import { YouTubeSyncProcessor } from './queues/processors/youtube-sync.processor'; +import { BulkMetadataProcessor } from './queues/processors/bulk-metadata.processor'; +import { RenderProcessor } from './queues/processors/render.processor'; +import { LintProcessor } from './queues/processors/lint.processor'; +import { ImportProcessor } from './queues/processors/import.processor'; +import { ConflictDetectionProcessor } from './queues/processors/conflict-detection.processor'; +import { ConflictDetectionScheduler } from './queues/schedulers/conflict-detection.scheduler'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + BullModule.forRootAsync({ + useFactory: (config: ConfigService) => ({ + connection: { url: config.get('REDIS_URL') ?? 'redis://localhost:6379' }, + }), + inject: [ConfigService], + }), + BullModule.registerQueue( + { name: QUEUES.YOUTUBE_SYNC }, + { name: QUEUES.CONFLICT_DETECTION }, + { name: QUEUES.BULK_METADATA }, + { name: QUEUES.RENDER }, + { name: QUEUES.LINT }, + { name: QUEUES.IMPORT }, + ), + PrismaModule, + RenderEngineModule, + QuotaModule, + AuditModule, + YouTubeSyncModule, + BulkJobsModule, + LintingModule, + ImportsModule, + ], + providers: [ + YouTubeSyncProcessor, + BulkMetadataProcessor, + RenderProcessor, + LintProcessor, + ImportProcessor, + ConflictDetectionProcessor, + ConflictDetectionScheduler, + ], +}) +export class WorkerModule {} diff --git a/backend/src/worker.ts b/backend/src/worker.ts new file mode 100644 index 0000000..8a6c426 --- /dev/null +++ b/backend/src/worker.ts @@ -0,0 +1,10 @@ +import { NestFactory } from '@nestjs/core'; +import { WorkerModule } from './worker.module'; + +async function bootstrap() { + const app = await NestFactory.createApplicationContext(WorkerModule); + app.enableShutdownHooks(); + console.log('StudioFlow Worker started'); +} + +bootstrap(); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..4b82434 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false + } +} diff --git a/claude.bat b/claude.bat new file mode 100644 index 0000000..d045c9e --- /dev/null +++ b/claude.bat @@ -0,0 +1,2 @@ +@echo off +call "%USERPROFILE%\bin\claude.cmd" %* \ No newline at end of file diff --git a/documentation/YouTube Studio Flow/.obsidian/app.json b/documentation/YouTube Studio Flow/.obsidian/app.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/documentation/YouTube Studio Flow/.obsidian/app.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/documentation/YouTube Studio Flow/.obsidian/appearance.json b/documentation/YouTube Studio Flow/.obsidian/appearance.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/documentation/YouTube Studio Flow/.obsidian/appearance.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/documentation/YouTube Studio Flow/.obsidian/core-plugins.json b/documentation/YouTube Studio Flow/.obsidian/core-plugins.json new file mode 100644 index 0000000..639b90d --- /dev/null +++ b/documentation/YouTube Studio Flow/.obsidian/core-plugins.json @@ -0,0 +1,33 @@ +{ + "file-explorer": true, + "global-search": true, + "switcher": true, + "graph": true, + "backlink": true, + "canvas": true, + "outgoing-link": true, + "tag-pane": true, + "footnotes": false, + "properties": true, + "page-preview": true, + "daily-notes": true, + "templates": true, + "note-composer": true, + "command-palette": true, + "slash-command": false, + "editor-status": true, + "bookmarks": true, + "markdown-importer": false, + "zk-prefixer": false, + "random-note": false, + "outline": true, + "word-count": true, + "slides": false, + "audio-recorder": false, + "workspaces": false, + "file-recovery": true, + "publish": false, + "sync": true, + "bases": true, + "webviewer": false +} \ No newline at end of file diff --git a/documentation/YouTube Studio Flow/.obsidian/graph.json b/documentation/YouTube Studio Flow/.obsidian/graph.json new file mode 100644 index 0000000..42a46ec --- /dev/null +++ b/documentation/YouTube Studio Flow/.obsidian/graph.json @@ -0,0 +1,22 @@ +{ + "collapse-filter": true, + "search": "", + "showTags": false, + "showAttachments": false, + "hideUnresolved": false, + "showOrphans": true, + "collapse-color-groups": true, + "colorGroups": [], + "collapse-display": true, + "showArrow": false, + "textFadeMultiplier": 0, + "nodeSizeMultiplier": 1, + "lineSizeMultiplier": 1, + "collapse-forces": true, + "centerStrength": 0.518713248970312, + "repelStrength": 10, + "linkStrength": 1, + "linkDistance": 250, + "scale": 1, + "close": true +} \ No newline at end of file diff --git a/documentation/YouTube Studio Flow/00 - Claude Guide.md b/documentation/YouTube Studio Flow/00 - Claude Guide.md new file mode 100644 index 0000000..954489c --- /dev/null +++ b/documentation/YouTube Studio Flow/00 - Claude Guide.md @@ -0,0 +1,60 @@ +# Claude Guide + +Guidance for working with this codebase as an AI assistant. Read this first, then follow the task routing table to find the specific docs for your task. + +--- + +## Task Routing + +Find your task type below and read only the listed docs. Do not read the entire vault for every task. + +| Task | Read first | +|---|---| +| Bug in description rendering or tokens | `01-Architecture/07 - Render Engine` + `05-Development/04 - Gotchas` | +| New backend module or service | `05-Development/03 - Recipes` + `04-Design-Guidelines/04 - Backend Architecture Patterns` | +| New API endpoint + frontend wiring | `05-Development/03 - Recipes` + `03-API-Reference/` | +| Frontend UI component or page | `04-Design-Guidelines/02 - CSS Conventions` + `03 - Component Patterns` | +| Frontend styling or visual change | `04-Design-Guidelines/01 - Visual Design` + `02 - CSS Conventions` | +| Database schema change | `01-Architecture/04 - Database Schema` + `05-Development/03 - Recipes` (Prisma recipe) | +| New lint rule | `05-Development/03 - Recipes` + `02-Features/03 - Metadata Linting` | +| Queue / background job issue | `01-Architecture/05 - Queue System` + `05-Development/04 - Gotchas` | +| Auth or permissions issue | `01-Architecture/06 - Authentication` + `04-Design-Guidelines/04 - Backend Architecture Patterns` | +| Unfamiliar with the codebase | `01-Architecture/01 - System Overview` → `02 - Backend` → `03 - Frontend` | + +After making changes, always verify using `05-Development/06 - Verifying Changes`. + +--- + +## Do Not Do These + +These are failure modes that come up repeatedly when working with LLMs in this codebase. + +**Do not create new utility or helper files.** If something needs a helper, add it to the relevant service or component. Check `05-Development/03 - Recipes` for the established pattern before creating anything new. + +**Do not add comments that describe what the code does.** Only comment when the *why* is non-obvious — a hidden constraint, a workaround for a specific bug, a subtle invariant. See `04-Design-Guidelines/05 - Code Conventions` for the full rule. + +**Do not introduce abstractions for things that appear in two or three places.** This codebase favors repetition over premature abstraction. Add an abstraction only when a pattern appears three or more times with meaningful variation and the abstraction is simpler than the repetition. + +**Do not use `SiYoutube` from `react-icons/si`.** It does not exist in v5. Use `FaYoutube` from `react-icons/fa` instead. All other platform icons are in `react-icons/si`. + +**Do not add error handling for scenarios that cannot happen.** Trust Prisma's type guarantees and internal service calls. Only validate and handle errors at system boundaries: user input, external API calls, and queue job payloads. + +**Do not register a new module only in `AppModule` if it is also needed by queue processors.** Always check whether the new module's services are used in any processor, and if so, register it in `WorkerModule` as well. Missing `WorkerModule` registration causes silent runtime errors in background jobs. + +**Do not remove or rename Prisma enum values.** PostgreSQL enum removal requires a raw SQL migration and risks data loss. Mark deprecated values as hidden in the UI instead. + +**Do not call `useQueryClient()` inside a callback or effect.** It must be called at the component top level and assigned to a local variable before use in `onSuccess` or event handlers. + +**Do not duplicate data-fetching logic for the render engine.** `VideoRenderService` is the single source of truth for fetching all data needed for a render. Never replicate its data-fetching in a processor or service — always go through `VideoRenderService`. + +--- + +## Key Invariants + +These are easy to miss and cause subtle bugs. + +- Every database query must be scoped to `req.user.teamId`. Never query across teams. +- Every user-facing mutation (create/update/delete) must call `AuditService.log()`. Background/system operations do not. +- New `{video.*}` or `{collab.*}` render tokens must be registered in `system-variables.registry.ts` or they will silently resolve to nothing. +- `Campaign.status` must be exactly the lowercase string `"active"` for the campaign to be treated as active. Any other casing is inactive. +- The `TOKEN_ENCRYPTION_KEY` env var must never change in production. Rotating it invalidates all stored YouTube OAuth tokens. diff --git a/documentation/YouTube Studio Flow/00 - Index.md b/documentation/YouTube Studio Flow/00 - Index.md new file mode 100644 index 0000000..184e8df --- /dev/null +++ b/documentation/YouTube Studio Flow/00 - Index.md @@ -0,0 +1,84 @@ +# YouTube Studio Flow — Documentation + +Multi-tenant YouTube channel management tool. Teams connect channels, manage video metadata via a description block and template system, and push changes back to YouTube. + +--- + +## For Claude — Start Here + +Read [[00 - Claude Guide]] before starting any task. It contains a task routing table (which docs to read for which task type) and a list of common mistakes to avoid in this codebase. + +--- + +## Quick Navigation + +### Architecture + +- [[01 - System Overview]] — Tech stack, process architecture, key decisions +- [[02 - Backend]] — NestJS modules, patterns, shared services +- [[03 - Frontend]] — Next.js routing, state management, API layer +- [[04 - Database Schema]] — Prisma models and relationships +- [[05 - Queue System]] — BullMQ processors and queue constants +- [[06 - Authentication]] — Google OAuth, JWT, role-based access +- [[07 - Render Engine]] — Description rendering pipeline, token resolution + +### Features + +- [[01 - Video Management]] — Video list, editor, sync +- [[02 - Description Engine]] — Blocks, templates, tokens, rendering +- [[03 - Metadata Linting]] — Rules, execution, disabling rules +- [[04 - Bulk Operations]] — Preview/confirm pattern, rollback +- [[05 - Playlists]] — YouTube playlist sync +- [[06 - Collaborators]] — Platform fields, token mapping +- [[07 - Saved Views]] — Filter presets, pinned tabs +- [[08 - Campaigns]] — Date-window promotional blocks +- [[09 - Calendar]] — Scheduled video visualization +- [[10 - Import Export]] — CSV and JSON workspace transfer +- [[11 - Quota Management]] — YouTube API quota tracking +- [[12 - Audit Log]] — Change history +- [[13 - Team Settings]] — Render settings, lint rule disabling + +### API Reference + +- [[01 - Auth API]] +- [[02 - Videos API]] +- [[03 - Blocks API]] +- [[04 - Templates API]] +- [[05 - Collaborators API]] +- [[06 - Linting API]] +- [[07 - Playlists API]] +- [[08 - Saved Views API]] +- [[09 - Teams API]] +- [[10 - Quota API]] +- [[11 - Bulk Jobs API]] +- [[12 - Calendar API]] +- [[13 - Import Export API]] +- [[14 - Team Variables API]] +- [[15 - Campaigns API]] +- [[16 - YouTube Sync API]] +- [[17 - Video Configs API]] + +### Design Guidelines + +- [[01 - Visual Design]] — Colors, typography, spacing, shadows +- [[02 - CSS Conventions]] — CSS Modules, variables, min-width rule +- [[03 - Component Patterns]] — React patterns, TanStack Query, modals +- [[04 - Backend Architecture Patterns]] — Module structure, scoping, audit +- [[05 - Code Conventions]] — TypeScript, naming, comments + +### Development + +- [[01 - Local Setup]] — Prerequisites and step-by-step setup +- [[02 - Environment Variables]] — All env vars with descriptions +- [[03 - Recipes]] — How to add modules, rules, endpoints, nav items +- [[04 - Gotchas]] — Non-obvious behaviors and known traps +- [[05 - Deployment and Operations]] — Production stack, secrets, job failures, backups, key rotation +- [[06 - Verifying Changes]] — Build, type-check, and lint commands + +### Backlog + +- [[01 - Technical Debt and Future Work]] — Dead code, missing features, deferred improvements + +### Daily Notes + +Chronological log of shipped work. Filename format: `YYYY-MM-DD NN Short title` (NN counts major tasks that day, ascending). diff --git a/documentation/YouTube Studio Flow/01 - Architecture/01 - System Overview.md b/documentation/YouTube Studio Flow/01 - Architecture/01 - System Overview.md new file mode 100644 index 0000000..4511bc3 --- /dev/null +++ b/documentation/YouTube Studio Flow/01 - Architecture/01 - System Overview.md @@ -0,0 +1,65 @@ +# System Overview + +YouTube Studio Flow is a multi-tenant SaaS tool for YouTube channel management. Teams connect their YouTube channels, import their video library, manage video metadata using a description template and block system, and push changes back to YouTube via the API. + +## Tech Stack + +| Layer | Technology | +|---|---| +| Frontend | Next.js 15 App Router, TypeScript, CSS Modules | +| Data fetching | TanStack Query v5 | +| Auth state | Zustand | +| Backend | NestJS 10, TypeScript | +| ORM | Prisma (PostgreSQL) | +| Queue | BullMQ + Redis | +| Auth | Google OAuth → JWT (access + refresh tokens) | +| YouTube integration | `googleapis` package, quota-tracked | + +## Process Architecture + +The backend runs as **two separate Node.js processes** from the same build: + +| Process | Entry point | Role | +|---|---|---| +| API server | `src/main.ts` | HTTP API on port 3001 | +| Queue worker | `src/worker.ts` | BullMQ job processor (no HTTP) | + +Both must be running for full functionality. The API enqueues jobs; the worker executes them. + +## System Diagram + +``` +Browser (Next.js) + │ + │ HTTP/REST (NEXT_PUBLIC_API_URL) + ▼ +NestJS API (:3001) + │ + ├──── PostgreSQL (Prisma) + ├──── Redis (BullMQ queues) + │ + └──── BullMQ worker process + │ + ├──── PostgreSQL (reads/writes) + └──── YouTube API (googleapis) +``` + +## Request Scoping + +Every authenticated request is scoped to a team. The JWT payload contains `userId` and `teamId`. All database queries must filter by `channel: { teamId }` or direct `teamId` field. See [[02 - Backend]] for the auth pattern. + +## Key Design Decisions + +- **Denormalized `lintStatus`**: `Video.lintStatus` is a cached column. Any code path that deletes `LintResult` rows must recompute it manually — it is not computed on-the-fly. +- **YouTube token encryption**: OAuth tokens are AES-256 encrypted in the DB using `TOKEN_ENCRYPTION_KEY`. If this key changes all channels need re-authentication. +- **Queue deduplication**: BullMQ job IDs are used for deduplication. The pattern is `lint-{videoId}` for lint-once and `lint-{videoId}-{timestamp}` for forced reruns. +- **No shared code**: Backend and frontend are fully independent — no shared packages, no shared types. The frontend maintains its own API interface definitions in `api.ts`. +- **`hasPendingChanges` null-hash fallback**: `Video.lastSyncedHash` is set at import time to establish the YouTube baseline. For videos that existed before hashing was introduced (i.e. `lastSyncedHash IS NULL`), `findAll` falls back to computing a synthetic baseline from `youtubeDescription` and `recordingDate: null` and compares that against the current state hash. This means pre-existing videos correctly show "push pending" only when local changes diverge from the YouTube state, rather than always appearing as pending. Re-importing a channel resets the hash baseline and will clear any locally-pending changes that have not yet been pushed. + +## Related + +- [[02 - Backend]] — Module structure, patterns, shared services +- [[03 - Frontend]] — Routing, state management, API layer +- [[04 - Database Schema]] — All Prisma models +- [[05 - Queue System]] — BullMQ queues and processors +- [[06 - Authentication]] — OAuth flow, JWT, guards diff --git a/documentation/YouTube Studio Flow/01 - Architecture/02 - Backend.md b/documentation/YouTube Studio Flow/01 - Architecture/02 - Backend.md new file mode 100644 index 0000000..b504fb6 --- /dev/null +++ b/documentation/YouTube Studio Flow/01 - Architecture/02 - Backend.md @@ -0,0 +1,99 @@ +# Backend Architecture + +## Entry Points + +| File | Role | +|---|---| +| `src/main.ts` | Starts the NestJS HTTP API on port 3001 | +| `src/worker.ts` | Starts the BullMQ worker (no HTTP server) | + +## Module Map + +``` +src/modules/ + auth/ Google OAuth, JWT strategy, guards + videos/ Video CRUD, bulk ops, sync enqueue, findAll with filters + video-configs/ Per-video description config (block order, overrides, variables) + blocks/ DescriptionBlock CRUD + versioning + templates/ Template CRUD, preview, apply-to-video + collaborators/ Collaborator CRUD + teams/ Team management, members, channels, settings, publishing schedule + team-variables/ Global team-level key/value variables + saved-views/ Saved filter presets for the video list + campaigns/ Campaign date windows (CAMPAIGN blocks auto-include when active) + playlists/ YouTube playlist sync + video↔playlist management + linting/ Rule-based lint checks on video metadata + bulk-jobs/ Bulk metadata operations (preview, execute, rollback) + youtube-sync/ Channel import (YouTubeApiClient, ChannelImportService) + calendar/ Scheduled video calendar view + imports/ CSV + JSON workspace import + exports/ CSV + JSON workspace export + quota/ YouTube API quota tracking and history + +src/shared/ + prisma/ PrismaService singleton + render-engine/ VideoRenderService + RenderEngineService + hashMetadata + audit/ AuditService — logs all user mutations + quota/ QuotaService — tracks and enforces YouTube API quota + system-variables/ Built-in {video.*} and {collab.*} token registry + +src/queues/processors/ + youtube-sync.processor.ts Renders description + pushes all fields to YouTube + render.processor.ts Background render without pushing + lint.processor.ts Runs all lint rules against a video + bulk-metadata.processor.ts Processes bulk job items + import.processor.ts CSV import processing +``` + +## Auth Pattern + +Every controller method receives the authenticated user via `@Req() req`. Extract: +- `req.user.id` — actorId (for audit logs, ownership checks) +- `req.user.teamId` — always scope DB queries to this team + +All routes are protected by `JwtAuthGuard`. Role-restricted routes additionally use `RolesGuard` with `@Roles(TeamRole.EDITOR)` or `@Roles(TeamRole.ADMIN)`. + +## Team Scoping Rule + +**Always** filter database queries by team. The ownership chain is: + +``` +Team → Channel → Video +``` + +To scope a video query: `where: { channel: { teamId: req.user.teamId } }` + +## Audit Logging + +`AuditService.log(actorId, entityType, entityId, action, before, after)` must be called for every user-facing mutation (create/update/delete). Background/system operations do not log. + +Tracked entity types: `Video`, `VideoConfig`, `DescriptionBlock`, `Template`, `Collaborator`, `TeamVariable`, `SavedView`, `TeamMember`. + +Modules that need audit logging must import `AuditModule` in their `@Module` imports array. + +## Shared Services + +### VideoRenderService +Single source of truth for description rendering. Fetches all required data (blocks, team variables, campaign blocks, collaborators, team date format, playlists) and delegates to `RenderEngineService`. Used by all three render paths: +- `youtube-sync.processor` — render + push +- `render.processor` — render only +- `videos.service.ts renderDescription()` — on-demand + +**Never duplicate the data-fetching logic.** Always go through `VideoRenderService`. + +### RenderEngineService +Pure computation. Takes `RenderInput`, returns `{ rendered: string, hash: string }`. Used directly only by `video-configs.service.ts renderPreview()` and `templates.service.ts renderPreview()` (which already have all data). + +### QuotaService +`canSpend(units)` and `spend(units, operation, meta)` must be called before any YouTube API write. Quota resets at midnight Pacific Time. `videos.update` costs 50 units; daily limit is 10,000. + +### AuditService +See Audit Logging above. + +## Related + +- [[01 - System Overview]] +- [[04 - Database Schema]] +- [[05 - Queue System]] +- [[07 - Render Engine]] +- [[03 - Metadata Linting]] (features) diff --git a/documentation/YouTube Studio Flow/01 - Architecture/03 - Frontend.md b/documentation/YouTube Studio Flow/01 - Architecture/03 - Frontend.md new file mode 100644 index 0000000..821f006 --- /dev/null +++ b/documentation/YouTube Studio Flow/01 - Architecture/03 - Frontend.md @@ -0,0 +1,99 @@ +# Frontend Architecture + +## Framework + +Next.js 15 App Router with TypeScript and CSS Modules. All pages under `src/app/(dashboard)/` are protected by `AuthGuard` and wrapped in the dashboard layout (sidebar + header). + +## Route Map + +| Route | Page | +|---|---| +| `/` | Redirects to `/overview` | +| `/overview` | Dashboard overview | +| `/videos` | Video list — paginated, sortable, filterable, with tabs | +| `/videos/[id]` | Video editor — metadata + description config | +| `/blocks` | Description block management | +| `/templates` | Template management | +| `/variables` | Team global variables | +| `/collaborators` | Collaborator management | +| `/settings` | Team members + connected channels | +| `/saved-views` | Saved filter preset management | +| `/linting` | Lint results browser — filter by severity/rule | +| `/bulk-jobs` | Bulk operation history + rollback | +| `/calendar` | Scheduled video calendar (month/week/agenda) | +| `/audit` | Change history / audit log | +| `/quota-history` | YouTube API quota usage history | +| `/io` | CSV / JSON import and export | +| `/login` | Google OAuth login | +| `/auth/callback` | OAuth callback handler | + +## Key Files + +| File | Role | +|---|---| +| `src/lib/api.ts` | All API call functions + TypeScript interfaces for every API response | +| `src/lib/api-client.ts` | Axios instance — sets `baseURL`, attaches JWT, handles 401 refresh | +| `src/store/useAuthStore.ts` | Zustand — `user`, `teamId`, `token`, `clearAuth` | +| `src/store/useUIStore.ts` | Zustand (persisted) — `sidebarCollapsed`, `toggleSidebar` | +| `src/middleware.ts` | Route protection — redirects unauthenticated users to `/login` | +| `src/styles/globals.css` | Design tokens (CSS variables), global utility classes | +| `src/hooks/useTheme.ts` | Dark/light theme toggle with localStorage persistence | + +## State Management + +| Concern | Tool | +|---|---| +| Server data (videos, blocks, etc.) | TanStack Query v5 `useQuery` / `useMutation` | +| Auth state | Zustand `useAuthStore` | +| UI state (sidebar) | Zustand `useUIStore` (localStorage-persisted) | +| Page-level UI state | `useState` / `useReducer` | + +## TanStack Query Patterns + +- `useQueryClient()` must be called at component level, never inside callbacks +- After mutations, invalidate related queries in `onSuccess`: + - After saving a video: invalidate `['video', id]` and `['videos']` + - After changing blocks: invalidate `['blocks']` and affected video queries +- Use `placeholderData: (prev) => prev` to keep stale data visible during page transitions +- Query keys are arrays: `['videos']`, `['video', id]`, `['preferences']` + +## API Layer + +All API calls are in `src/lib/api.ts`. The file exports: +- TypeScript interfaces for all API response shapes +- Named functions for each endpoint (`fetchVideos`, `updateVideo`, `lintVideo`, etc.) + +The Axios client (`api-client.ts`) handles: +- Base URL from `NEXT_PUBLIC_API_URL` +- JWT `Authorization: Bearer` header injection +- 401 → automatic token refresh → retry + +## Component Structure + +``` +src/components/ + video-table/ + VideoTable.tsx TanStack Table — video list with sorting, pagination + VideoTable.module.css + video-config/ + VideoConfigEditor.tsx forwardRef component — exposes save() and isDirty() + VideoConfigEditor.module.css + shared/ + Sidebar.tsx Collapsible navigation sidebar + Header.tsx Top bar — search, sync status, bulk change, theme, user + Modal.tsx Generic modal wrapper + FormField.module.css Shared form field utility classes + Providers.tsx TanStack Query provider wrapper + AuthGuard.tsx Redirects unauthenticated users + SortableSection.tsx DnD-kit sortable wrapper for video editor sections +``` + +## Video Editor Layout + +The video editor (`/videos/[id]`) uses a combined horizontal header card (thumbnail + meta + actions) above a two-column sortable editing area. Sections (Basic Info, Language, Audience, Playlists, Description) can be dragged between the two columns. Column layout is persisted to `User.preferences` via `PATCH /users/me/preferences`. + +## Related + +- [[01 - System Overview]] +- [[01 - Visual Design]] (design guidelines) +- [[02 - CSS Conventions]] (design guidelines) diff --git a/documentation/YouTube Studio Flow/01 - Architecture/04 - Database Schema.md b/documentation/YouTube Studio Flow/01 - Architecture/04 - Database Schema.md new file mode 100644 index 0000000..77abab1 --- /dev/null +++ b/documentation/YouTube Studio Flow/01 - Architecture/04 - Database Schema.md @@ -0,0 +1,453 @@ +# Database Schema + +PostgreSQL via Prisma. Schema at `backend/prisma/schema.prisma`. + +## Model Overview + +``` +User + └── TeamMember[] (many-to-many with Team) + +Team + ├── TeamMember[] + ├── Channel[] + ├── DescriptionBlock[] + ├── Template[] + ├── Collaborator[] + ├── BulkJob[] + ├── SavedView[] + ├── ImportJob[] + ├── ExportJob[] + ├── Campaign[] + └── TeamVariable[] + +Channel + ├── Video[] + ├── Playlist[] + └── QuotaLog[] + +Video + ├── VideoConfig (1:1) + ├── VideoPlaylist[] (many-to-many with Playlist) + ├── LintResult[] + ├── BulkJobItem[] + └── QuotaLog[] + +DescriptionBlock + └── BlockVersion[] + +Template + └── TemplateVersion[] + +BulkJob + └── BulkJobItem[] + +Playlist + └── VideoPlaylist[] (many-to-many with Video) +``` + +## Key Models + +### User +``` +id, email, name, googleId, isAppAdmin, preferences (Json), createdAt, updatedAt +``` +`preferences` is a freeform JSON field used to persist per-user UI state (column visibility, column order, video editor section layout). + +### Team +``` +id, name, slug, +dateFormat (String?), +timezone (String, default "UTC"), +publishingSchedule (Json?), +showCanvaLink (Boolean), +disabledLintRules (String[]), +showDeletedVideos (Boolean), +conflictDetectionEnabled (Boolean, default false), +conflictDetectionBatchSize (Int, default 50), +conflictDetectionMinAgeDays (Int, default 7) +``` +The three `conflictDetection*` fields configure the scheduled remote-conflict sweep. `Enabled` is per-team opt-in; `batchSize` caps how many videos are scanned per run (1–500); `minAgeDays` skips videos synced more recently than N days. Global on/off and cron pattern live in env vars (`CONFLICT_DETECTION_ENABLED`, `CONFLICT_DETECTION_CRON`). + +### Channel +``` +id, teamId, youtubeChannelId (unique), name, uploadsPlaylistId, +supplementalVideoIds (String[]), +youtubeAccessToken (encrypted), youtubeRefreshToken (encrypted), youtubeTokenExpiry, +connectedBy +``` +OAuth tokens are AES-256 encrypted using `TOKEN_ENCRYPTION_KEY`. Changing this key breaks all existing channel connections. + +`supplementalVideoIds` is an array of YouTube video IDs that are always imported during `channel-import` and `channel-full-refresh` runs, regardless of whether they appear in the uploads playlist. This exists for unlisted or otherwise playlist-excluded videos that the team still wants to manage. On each import the list is filtered against the playlist results so only IDs not already discovered via the playlist are fetched as extras. New IDs are added via `POST /youtube-sync/channels/:channelId/supplemental-ids`. + +### Video +``` +id, youtubeVideoId (unique), channelId, title, +youtubeDescription, renderedDescription, thumbnailUrl, +tags (String[]), categoryId, privacyStatus (PUBLIC/PRIVATE/UNLISTED), +publishedAt, scheduledAt, templateId, +selfDeclaredMadeForKids, embeddable, license, defaultLanguage, defaultAudioLanguage, +recordingDate, gameTitle, +collaboratorIds (Json, default "[]"), +youtubeSnapshot (Json?), +lintStatus (OK/WARNING/ERROR), +lastSyncedAt, lastSyncedHash, +remoteConflict (Boolean), +pendingRemoteSnapshot (Json?), pendingRemoteDescription (String?), +youtubeDeletedAt +``` + +**Important fields:** +- `lintStatus` — denormalized cache. Must be recomputed by any code that deletes `LintResult` rows +- `lastSyncedHash` — hash of YouTube-side state. Compared against current state to compute `hasPendingChanges` +- `youtubeSnapshot` — the *last intentionally-synced* YouTube metadata (written on push and on refresh). Used for field-level diff +- `collaboratorIds` — JSON array of Collaborator IDs used in description rendering +- `remoteConflict` — set to `true` when YouTube-side metadata diverges from `lastSyncedHash`, meaning something changed on YouTube outside of this tool since the last push. Detected via two paths: (1) manual `POST /videos/:id/refresh`, or (2) the scheduled `CONFLICT_DETECTION` queue when the team opts in (`Team.conflictDetectionEnabled`). Cleared on a successful YouTube push, on `POST /videos/:id/accept-remote`, or on a self-heal pass where the remote hash matches `lastSyncedHash` again. +- `pendingRemoteSnapshot` / `pendingRemoteDescription` — written by `detectConflict()` when a mismatch is found. Snapshot mirrors the `youtubeSnapshot` shape and holds the freshly fetched remote state; description holds the raw remote description. Consumed by `POST /videos/:id/accept-remote` (no extra YouTube call). Cleared when the conflict is resolved. + +### VideoConfig +``` +id, videoId (unique), templateId?, +blockOrder (Json, String[]), +blockOverrides (Json), +variableValues (Json), +version, renderHash, renderedAt +``` +`blockOrder` may contain both real block IDs and `freetext:{uuid}` IDs. Freetext content is stored in `blockOverrides[id].content`. + +### DescriptionBlock +``` +id, teamId, name, type (BlockType), content, language, +campaignId?, version, active, compact, +tags (String[]), variableDefinitions (Json), condition (Json?) +``` + +**BlockType enum values:** +- `STATIC` — raw output, no token substitution +- `VARIABLE` — resolves `{custom_var}`, `{video.*}`, `{collab.*}` tokens +- `CAMPAIGN` — auto-included at end of render when linked campaign is active +- `CONDITIONAL`, `COLLABORATOR` — contextual blocks +- `GLOBAL`, `REPEATABLE` — deprecated (kept in enum, hidden in UI) + +### Template +``` +id, teamId, name, description?, +defaultBlocks (Json, String[]), +defaultOverrides (Json), +rules (Json), +variables (Json), +videoFields (Json?), +version, active +``` +`rules.requiredLinks` is used by the `DESC_REQUIRED_LINK_MISSING` lint rule. +`videoFields` contains default values for video metadata fields applied when the template is assigned. + +### TeamMember +``` +userId (PK), teamId (PK), role (TeamRole, default EDITOR), createdAt +``` +Composite primary key `(userId, teamId)`. Represents a user's membership in a team. + +**TeamRole enum:** `OWNER`, `ADMIN`, `EDITOR`, `REVIEWER`, `READONLY` + +`OWNER` is set at team creation and cannot be assigned via the invite API. See [[06 - Authentication]] for role hierarchy and permission levels. + +### TeamVariable +``` +id, teamId, name, value, createdAt, updatedAt +``` +Unique constraint on `(teamId, name)` — variable names must be unique per team. The `name` is used as the token key: `{name}` in `VARIABLE`-type description blocks. Team variables form the baseline layer; video-level `variableValues` override them per-video. + +Any mutation (create / update / delete) triggers a background re-render of all team videos. + +### Collaborator +``` +id, teamId, name, +youtubeLink?, twitchLink?, instagramLink?, tiktokLink?, +twitterLink?, blueskyLink?, discordHandle?, +aliases (String[]), +active (Boolean, default true), +notes?, +createdAt, updatedAt +``` +Platform link fields are full URLs except `discordHandle` which is a handle string. All are optional. `aliases` is an array of alternative names used in search and token display. + +Collaborators are assigned to a video via `Video.collaboratorIds` (JSON string array). This is the single source used by the render engine to resolve `{collab.*}` tokens and by the calendar and filter queries to check collaborator assignment. There is no separate join table. + +### Campaign +``` +id, teamId, name, startAt, endAt?, status (String, default "active"), notes? +``` +`endAt` is optional — `null` means the campaign is open-ended. `status` is a free-form string (not an enum), default `"active"`. + +At render time, any `CAMPAIGN`-type `DescriptionBlock` linked to this campaign (via `campaignId`) is automatically appended to the description if `startAt ≤ now ≤ endAt`. Campaign CRUD is not exposed via the REST API — managed directly in the database. + +### BlockVersion +``` +id, blockId, version (Int), contentSnapshot (Json), createdAt, createdBy? +``` +Append-only history table. A new row is written each time a `DescriptionBlock` is saved. `contentSnapshot` stores the full block content at that version. `createdBy` is the user ID of the actor who saved it (nullable for system/migration writes). + +`version` mirrors `DescriptionBlock.version` at the time of the snapshot. Versions are used to show change history in the block editor. + +### TemplateVersion +``` +id, templateId, version (Int), snapshot (Json), createdAt +``` +Same append-only pattern as `BlockVersion`. `snapshot` stores the full template state (including `defaultBlocks`, `defaultOverrides`, `rules`, `variables`, `videoFields`) at the time of save. + +### LintResult +``` +id, videoId, ruleCode, severity (INFO/WARNING/ERROR), +targetField?, message, fixSuggestion?, resolvedAt? +``` +Unresolved results have `resolvedAt: null`. Resolving marks `resolvedAt = now()` and triggers `lintStatus` recomputation on the video. + +### SavedView +``` +id, teamId, name, description?, +isGlobal, ownerId?, +queryJson, columnsJson, sortJson?, +pinnedAsTab (Boolean), tabOrder (Int?) +``` + +### BulkJob +``` +id, teamId, type, initiatedBy, filterSnapshot, targetIds, +status (PENDING/DRY_RUN/CONFIRMED/RUNNING/DONE/FAILED/ROLLED_BACK), +dryRunResult?, rollbackData?, totalCount, successCount, errorCount +``` + +### BulkJobItem +``` +id, bulkJobId, videoId, beforeSnapshot (Json?), afterSnapshot (Json?), +status (String, default "pending"), errorMessage? +createdAt +``` +One row per video in a bulk job. `status` values: `"pending"`, `"done"`, `"error"`. `beforeSnapshot` and `afterSnapshot` store the YouTube metadata before and after the operation — used for rollback and the change diff display in the UI. + +### Playlist +``` +id, channelId, youtubePlaylistId (unique), title, description?, +itemCount (Int, default 0), privacyStatus (String, default "public"), +createdAt, updatedAt +``` +Synced from YouTube. `itemCount` is cached and used to detect playlist changes without fetching all items on every import. + +### VideoPlaylist +``` +videoId (PK), playlistId (PK), position (Int?) +``` +Composite primary key join table. `position` is the video's position within the playlist as returned by YouTube, if available. + +### ImportJob +``` +id, teamId, type (String), sourceName, mappingJson (Json?), +validationReport (Json?), commitStatus (String, default "pending"), +createdBy, createdAt, committedAt? +``` +Tracks a CSV import session. `type` identifies the import variant (e.g. `"csv-videos"`). `mappingJson` stores the column-to-field mapping chosen during the import wizard. `validationReport` stores per-row validation results. `commitStatus` transitions: `"pending"` → `"committed"`. + +### ExportJob +``` +id, teamId, type (String), scopeJson (Json), +fileReference?, createdBy, createdAt +``` +Records each export operation. `scopeJson` captures the filter/selection that was exported. `fileReference` stores the path or identifier of the generated export file. + +### QuotaLog +``` +id, datePt, units, operation, entityId?, channelId?, videoId?, bulkJobId?, actionId?, actionType? +``` +`actionId` groups related quota entries (e.g. all API calls for a single video sync). Used for quota history grouping in the frontend. + +### AuditLog +``` +id, actorId, entityType, entityId, action, beforeJson?, afterJson?, requestId? +``` + +## JSON Field Schemas + +### Team.publishingSchedule + +```typescript +type PublishingSlot = { + days: number[]; // weekdays: 0 = Sunday … 6 = Saturday. Empty array = every day. + time: string; // "HH:MM" in 24h format, interpreted in the team's IANA timezone +}; + +// Stored as: PublishingSlot[] | null +``` + +Used by `GET /teams/:teamId/next-publish-slot`. The algorithm walks up to 90 days forward, checks each slot against already-scheduled videos (±30 min collision window), and returns the first free slot. + +--- + +### DescriptionBlock.condition + +Only present on `CONDITIONAL`-type blocks. If absent or `rules` is empty, the block always renders. + +```typescript +type Condition = { + combinator?: 'and' | 'or'; // default: 'and' + rules: ConditionRule[]; +}; + +type ConditionRule = + | { type: 'variable_filled'; variable: string } // true if variable has a non-empty value + | { type: 'variable_empty'; variable: string } // true if variable is absent or empty + | { type: 'collab_count'; + operator: 'eq' | 'gt' | 'lt' | 'gte' | 'lte'; + value: number; // compared against number of assigned collaborators + }; +``` + +`variable` refers to the resolved variable name (after team + video-level override merge). An unknown variable evaluates as empty. Unknown rule `type` values default to `true`. + +--- + +### DescriptionBlock.variableDefinitions + +Declares which custom variable tokens this block expects. Used by the UI to render per-block variable inputs. + +```typescript +type BlockVariableDefinition = { + name: string; // token name (without braces), e.g. "sponsor_name" + label: string; // human-readable label shown in the UI + description?: string; // optional tooltip text + defaultValue?: string; // pre-filled default +}; + +// Stored as: BlockVariableDefinition[] +``` + +--- + +### BulkJob.filterSnapshot + +Stores the original request that created the job. Shape varies by job type: + +**Metadata bulk jobs** (`SET_PRIVACY`, `SET_TEMPLATE`, `ADD_TAGS`, `REMOVE_TAGS`, `SEARCH_REPLACE_TITLE`): +```typescript +{ + type: string; // job type, e.g. "SET_PRIVACY" + videoIds?: string[]; // explicit video IDs, or... + savedViewId?: string; // ...a saved view to resolve IDs from + payload: { + // SET_PRIVACY: + privacyStatus?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED'; + // SET_TEMPLATE: + templateId?: string; + // ADD_TAGS / REMOVE_TAGS: + tags?: string[]; + // SEARCH_REPLACE_TITLE: + search?: string; + replace?: string; + }; +} +``` + +**Sync-push bulk jobs** (`SYNC_PUSH`): +```typescript +{ + type: 'SYNC_PUSH'; + videoIds: string[]; // already-resolved and ownership-verified video IDs +} +``` + +--- + +### BulkJob.rollbackData + +**Unused.** This field exists in the schema but is never written or read by the application. Rollback is implemented entirely through `BulkJobItem.beforeSnapshot` — each item stores the pre-operation video snapshot, and the rollback endpoint restores it per-item. `rollbackData` is a vestigial field. + +--- + +### BulkJobItem.beforeSnapshot / afterSnapshot + +Stores a snapshot of the video's mutable fields before and after the bulk operation. Shape matches the fields relevant to the job type: + +```typescript +{ + title: string; + tags: string[]; + privacyStatus: string; + templateId: string | null; +} +``` + +Used by the rollback endpoint: `beforeSnapshot` is written back to `Video` directly via `prisma.video.update({ data: item.beforeSnapshot })`. + +For SYNC_PUSH jobs, these hold the `youtubeSnapshot` before and after the push. + +--- + +### ImportJob.validationReport + +Shape differs by import type. + +**CSV import:** +```typescript +{ + validCount: number; + errorCount: number; + errors: Array<{ + row: number; // 1-based row number in the CSV + field: string; // field name that failed validation + message: string; // zod validation message + }>; +} +``` + +**JSON workspace import:** +```typescript +{ valid: true } +``` + +JSON validation only checks that the top-level shape is correct (`version`, optional `videos`/`blocks`/`templates`/`collaborators` arrays). No per-item validation is performed during preview. + +--- + +### ImportJob.mappingJson + +Optional. Stores the column-to-field mapping the user selected during the CSV import wizard. Free-form shape — passed through from the frontend and not parsed by the backend during commit. + +--- + +### JSON workspace import payload (`POST /imports/json/preview` + commit) + +The workspace JSON format (used for both import and export): + +```typescript +{ + version: string; // schema version, currently "1.0" + exportedAt?: string; // ISO datetime (present on exports, ignored on import) + videos?: Video[]; // full Video rows + videoConfigs?: VideoConfig[]; + blocks?: DescriptionBlock[]; + templates?: Template[]; + collaborators?: Collaborator[]; + savedViews?: SavedView[]; +} +``` + +On commit, `collaborators`, `blocks`, and `templates` are upserted by `id`. The `teamId` from the current session overwrites whatever `teamId` is in the payload. `videos`, `videoConfigs`, and `savedViews` are **not** processed during JSON import commit — only the three content types above. + +--- + +### ExportJob.scopeJson + +**Unused.** `ExportJob` rows are never created by the application. The export service (`ExportsService`) returns data directly from the database without recording an export job. This model and field are schema artefacts with no active code path. + +--- + +## Schema Rules + +- **Never remove Prisma enum values** — PostgreSQL enum removal requires raw SQL migration and is risky. Mark deprecated values in UI instead. +- **After any schema change:** stop backend → `npx prisma generate` → `npx prisma migrate deploy` → restart backend +- **JSON columns** use Prisma `Json` type. Query with `array_contains` operator for JSON array fields (e.g. `collaboratorIds`) + +## Related + +- [[01 - System Overview]] +- [[02 - Backend]] diff --git a/documentation/YouTube Studio Flow/01 - Architecture/05 - Queue System.md b/documentation/YouTube Studio Flow/01 - Architecture/05 - Queue System.md new file mode 100644 index 0000000..9e363b6 --- /dev/null +++ b/documentation/YouTube Studio Flow/01 - Architecture/05 - Queue System.md @@ -0,0 +1,112 @@ +# Queue System + +BullMQ on Redis. The API process enqueues jobs; the worker process (`src/worker.ts`) consumes them. + +## Critical Redis Requirement + +Redis **must** run with `--maxmemory-policy noeviction`. BullMQ silently loses jobs if Redis uses `allkeys-lru`. This is pre-configured in `infrastructure/docker-compose.yml`. + +## Queue Names + +Defined in `backend/src/queues/queues.constants.ts`: + +| Constant | Queue name | +|---|---| +| `QUEUES.YOUTUBE_SYNC` | `youtube-sync` | +| `QUEUES.RENDER` | `render` | +| `QUEUES.LINT` | `lint` | +| `QUEUES.BULK_METADATA` | `bulk-metadata` | +| `QUEUES.IMPORT` | `import` | +| `QUEUES.CONFLICT_DETECTION` | `conflict-detection` | + +## Processors + +### `youtube-sync.processor.ts` +**Trigger:** `POST /videos/:id/sync` +**Job data:** `{ videoId }` +**Behavior:** +1. Calls `VideoRenderService` to render description and compute hash +2. Skips push if hash matches `lastSyncedHash` (no changes) +3. Calls `QuotaService.canSpend(50)` — rejects if quota exceeded +4. Pushes all editable fields to YouTube API (`videos.update`) +5. Updates `renderedDescription`, `lastSyncedHash`, `lastSyncedAt` on the video row + +### `render.processor.ts` +**Trigger:** `PUT /video-configs/:videoId` when the request body includes `"autoRender": true` +**Job data:** `{ videoId }` +**Behavior:** Renders description without pushing to YouTube. Updates `renderedDescription`. + +The render job is not enqueued automatically on every config save — it is opt-in via the `autoRender` flag. `VideoConfigsService.upsert()` checks `if (dto.autoRender)` and only then calls `renderQueue.add(...)`. The frontend passes `autoRender: true` when saving from the video editor so the preview updates in the background. Saves that don't need an immediate re-render (e.g. bulk template applies) omit the flag and skip the queue. + +### `lint.processor.ts` +**Trigger:** `POST /lint/bulk`, `POST /lint/channel/:id`, `POST /lint/team` +**Job data:** `{ videoId }` +**Behavior:** Calls `LintingService.lintVideo(videoId)`. Replaces all unresolved `LintResult` rows and recomputes `Video.lintStatus`. + +> **Note:** `POST /lint/videos/:id` (single video) is **synchronous** — it calls `LintingService.lintVideo()` directly in the HTTP handler and returns results immediately. It does not use this queue. Only bulk operations go through BullMQ. + +### `bulk-metadata.processor.ts` +**Trigger:** Bulk job confirmed by user +**Job data:** `{ bulkJobId }` +**Behavior:** Processes each `BulkJobItem` in sequence. Updates video fields, saves rollback snapshots, updates job status counters. + +### `import.processor.ts` +**Trigger:** CSV import committed (`POST /imports/csv/commit`) +**Job data:** `{ importJobId }` +**Behavior:** Calls `ImportsService.executeCommit()`, which marks the `ImportJob` as `committed` and writes an audit log entry. **Does not create or update any Video rows.** The preview step does not persist the validated rows, so the processor has no data to act on. CSV import is structurally incomplete — see the backlog. + +### `conflict-detection.processor.ts` +**Trigger:** BullMQ repeatable job registered on worker startup by `ConflictDetectionScheduler`. Cron pattern comes from `CONFLICT_DETECTION_CRON` (default `0 3 * * *`). Registration is gated by the global `CONFLICT_DETECTION_ENABLED` env var. +**Job data:** `{}` +**Behavior:** +1. Loads all teams with `conflictDetectionEnabled: true` +2. For each team, selects videos where `remoteConflict = false`, `youtubeDeletedAt = null`, and `lastSyncedAt < now - minAgeDays`, stalest-first, capped at `batchSize` +3. Hands the selected video IDs to `YouTubeSyncService.detectConflictsForVideos()`, which groups by `channelId` (needed for OAuth) and issues one `videos.list` call per batch of up to 50 IDs. Cost is **1 quota unit per batch**, not per video (YouTube quota is per method-call, invariant to the number of parts or IDs). `QuotaService.canSpend(1)` is checked before each batch. +4. Per video within a batch: on hash mismatch, writes `pendingRemoteSnapshot` + `pendingRemoteDescription` and sets `remoteConflict = true`. On hash match with a stale flag, clears the flag and pending fields — self-heals when the creator reverts an out-of-band edit. +5. Stops the entire sweep as soon as quota is exhausted; resumes on the next cron tick. + +`Team.conflictDetectionBatchSize` (1–500) and `conflictDetectionMinAgeDays` (≥0) are per-team knobs. Because the API cost is per batch (up to 50 videos each), a `batchSize` of 500 consumes ~10 units per team per run — not 500. Users resolve detected conflicts via `POST /videos/:id/accept-remote` (adopt remote, zero extra quota) or `POST /videos/:id/sync` (push local, overwrite remote). + +## Frontend Job Completion Detection + +There is **no push mechanism** (no SSE, no WebSocket, no EventEmitter). The frontend detects job completion entirely through polling via TanStack Query's `refetchInterval`. + +### Sync queue status — dynamic polling + +The `
` component (`components/shared/Header.tsx`) polls `GET /youtube-sync/queue-status` continuously with an interval that adapts to queue state: + +| Queue state | Poll interval | +|---|---| +| Active jobs running | 3 s | +| Jobs waiting | 8 s | +| Idle | 30 s | + +This drives the sync-in-progress indicator in the header. When the queue drains, the indicator clears within one polling cycle. + +### Video editor — scheduled invalidations + +After `POST /videos/:id/sync` is enqueued, the video editor schedules two forced refetches of `['video', id]` — at **3 s** and **8 s** — to pick up the updated `hasPendingChanges` once the processor finishes. If the job takes longer than 8 s, the UI shows stale "push pending" status until the next sync-status poll triggers a broader refresh. + +### Bulk jobs page — fixed polling + +`/bulk-jobs` polls `GET /bulk-jobs` every **5 s** via `refetchInterval: 5000`. Job status transitions (pending → processing → completed) are reflected within one polling cycle. + +### Linting page — fixed polling + immediate invalidation + +`/linting` polls `GET /lint/results` every **30 s**. After enqueuing a bulk lint operation, the mutation's `onSuccess` immediately invalidates `['lintResults']` and `['videos']` for a faster first update. + +### Implication + +Because completion detection is polling-based, the UI does not reflect job results in real time — there is always a latency of up to one polling interval. For sync jobs that take longer than 8 s, the video editor in particular may lag. If tighter feedback is needed in future, SSE on the sync queue endpoint would be the natural addition. + +--- + +## Job ID Deduplication + +- Lint-once: `jobId: lint-{videoId}` — BullMQ ignores duplicate job IDs, so submitting the same video twice before the job runs only creates one job +- Forced rerun: `jobId: lint-{videoId}-{timestamp}` — always creates a new job + +## Related + +- [[02 - Backend]] +- [[05 - Local Setup]] (Redis setup) diff --git a/documentation/YouTube Studio Flow/01 - Architecture/06 - Authentication.md b/documentation/YouTube Studio Flow/01 - Architecture/06 - Authentication.md new file mode 100644 index 0000000..89d0522 --- /dev/null +++ b/documentation/YouTube Studio Flow/01 - Architecture/06 - Authentication.md @@ -0,0 +1,112 @@ +# Authentication + +## Flow + +1. User clicks "Sign in with Google" → frontend redirects to `GET /auth/google` +2. Backend redirects to Google OAuth consent screen +3. Google redirects to `GET /auth/google/callback` with auth code +4. Backend exchanges code for tokens, upserts `User` record, then branches on team membership (see below) +5. Backend issues JWT access token (short-lived) + refresh token (long-lived) +6. Frontend stores tokens; `api-client.ts` attaches `Authorization: Bearer ` to all requests +7. On 401: `api-client.ts` automatically calls `POST /auth/refresh` and retries the original request + +## First Login vs. Returning User + +The OAuth callback handler (`AuthService.upsertGoogleUser`) checks whether the authenticating user already has any `TeamMember` rows. + +### New user (no team memberships) + +A full onboarding sequence runs automatically — no separate onboarding step or UI flow exists: + +1. YouTube API is called with the login OAuth tokens to fetch the user's own channel (`channels.list?mine=true`), retrieving the YouTube channel ID, channel name, and uploads playlist ID. +2. A `Team` is created using the user's display name as the team name, with a URL-safe slug derived from it. +3. A `Channel` is created and linked to the team, with the YouTube OAuth tokens stored AES-256 encrypted. +4. A `TeamMember` row is created linking the user to the team with role `OWNER`. +5. The JWT is issued with this new team's ID. + +This means the first Google login is both account creation **and** channel connection in one step. There is no separate "connect your channel" screen. + +### Returning user (already has team memberships) + +1. The user's existing `TeamMember` rows are loaded, ordered by `createdAt` ascending. +2. The first membership's `teamId` and `role` are used for the JWT. +3. The YouTube access token is refreshed on all `Channel` rows where `connectedBy = user.id`. The refresh token is only overwritten if Google returns a new one (which only happens on first auth or forced re-consent). + +### Invited user + +Invitations (`POST /teams/:teamId/members`) create a `TeamMember` row immediately. **The invitee must have already logged in at least once** — invite lookup is by `User.email`, and if no `User` row exists for that email the invite throws a 404. There is no pending-invite or email-notification system. + +When an invited user subsequently logs in: +- They already have at least one `TeamMember` row (the invite). +- They fall into the **returning user** path — no new team is created. +- If this is their very first login, their `User` record is created by `upsertGoogleUser` before the membership check, so the invite is found correctly. + +## JWT Payload + +```typescript +{ + sub: string; // User.id + email: string; + teamId: string; // Active team for this session + teamRole: TeamRole; +} +``` + +On first login the active team is the user's primary team (earliest `TeamMember.createdAt`). After calling `POST /auth/switch-team` the returned token carries the new team's ID and role. + +## Team Switching + +A user who belongs to multiple teams can switch the active team without logging out: + +1. Call `GET /teams/mine` to get the list of teams the user belongs to. +2. Call `POST /auth/switch-team` with `{ "teamId": "" }` — returns `{ "accessToken": "..." }`. +3. Replace the stored access token with the new one. All subsequent API calls are now scoped to the new team. + +The backend validates that the user is actually a member of the requested team before issuing the new token. + +**Current limitation:** The frontend has no UI for this. `POST /auth/switch-team` and `GET /teams/mine` are not called anywhere in the frontend codebase. Team switching is currently only possible via direct API calls. + +## Guards + +| Guard | Usage | +|---|---| +| `JwtAuthGuard` | Applied to all protected routes via `@UseGuards(JwtAuthGuard)` | +| `RolesGuard` | Applied alongside JwtAuthGuard; uses `@Roles(TeamRole.EDITOR)` decorator | + +## Team Roles + +| Role | Permissions | +|---|---| +| `OWNER` | All permissions | +| `ADMIN` | All permissions except ownership transfer | +| `EDITOR` | Create/update/delete content | +| `REVIEWER` | Read only (see note below) | +| `READONLY` | Read only | + +`OWNER` is assigned automatically when a team is created (first Google login). It cannot be assigned via the invite or role-update endpoints — both reject `role: OWNER` with `403 Forbidden`. There is no ownership transfer mechanism. The OWNER's role cannot be changed by anyone, and the OWNER cannot be removed from the team. + +No endpoint is gated exclusively to `OWNER` — every `@Roles` guard that lists `OWNER` also lists `ADMIN`. Because the guard uses priority ordering, ADMIN users have identical feature access. If an OWNER's Google account becomes inaccessible, ADMIN members can continue using the application without restriction. The only consequence is that the orphaned `TeamMember` row with `role: OWNER` cannot be removed via the API (it would require direct DB access). Since the YouTube channel is bound to the same Google account as the OWNER, a lost Google account means the channel itself is also inaccessible — an in-app ownership transfer would not recover YouTube access. + +> **REVIEWER and READONLY are currently functionally identical.** The `REVIEWER` role sits at priority 2 in the `RolesGuard` hierarchy (above `READONLY` at 1), but no endpoint specifies `REVIEWER` as a minimum role. The intended "comment" capability does not exist — there is no comment model, no comment endpoints, and no UI for comments. Until a comment system is built, assigning `REVIEWER` grants exactly the same access as `READONLY`. See the backlog. + +## sf_session Cookie + +`sf_session` is a **frontend-only routing flag** — it is not a backend session and carries no auth data. It is set to `1` by the frontend via `document.cookie` after a successful login or token refresh, and cleared on logout or failed refresh. + +Its sole purpose is to give Next.js `middleware.ts` something to check. Middleware runs on the edge before the React app hydrates and cannot access Zustand or `localStorage` (where the real JWT lives). Reading this cookie is the only way the middleware can distinguish authenticated from unauthenticated requests and redirect accordingly. + +The backend does not read or set `sf_session`. The backend sets a `refresh_token` httpOnly cookie for token refresh purposes. + +**Limitation:** `sf_session` is a presence flag, not a validity check. If the cookie persists after a token is revoked (e.g. due to a crash before the clear runs), middleware lets the request through and the client gets 401s from the API, which then redirects to `/login` client-side. This is acceptable given the current architecture. + +## Token Security + +- YouTube OAuth tokens are AES-256 encrypted in the database using `TOKEN_ENCRYPTION_KEY` (must be exactly 32 characters) +- JWT signing uses separate `JWT_SECRET` and `JWT_REFRESH_SECRET` +- The backend sets a `refresh_token` httpOnly cookie on login and clears it on logout +- Access tokens are stored in Zustand (persisted to `localStorage`) and attached to every request as `Authorization: Bearer ` by `api-client.ts` + +## Related + +- [[01 - System Overview]] +- [[02 - Environment Variables]] (development) diff --git a/documentation/YouTube Studio Flow/01 - Architecture/07 - Render Engine.md b/documentation/YouTube Studio Flow/01 - Architecture/07 - Render Engine.md new file mode 100644 index 0000000..6461f3f --- /dev/null +++ b/documentation/YouTube Studio Flow/01 - Architecture/07 - Render Engine.md @@ -0,0 +1,151 @@ +# Render Engine + +## Two Services + +### VideoRenderService +**Location:** `backend/src/shared/render-engine/video-render.service.ts` + +The **single source of truth** for description rendering. Responsible for fetching all required data: +- Description blocks (from `blockOrder`) +- Block overrides and variable values (from `VideoConfig`) +- Team variables +- Campaign blocks (active ones are appended regardless of `blockOrder`) +- Team date format and timezone +- Playlists (for `{video.playlists}` token) +- Collaborators (for `{collab.*}` tokens) + +Then delegates computation to `RenderEngineService`. + +**Used by:** `youtube-sync.processor`, `render.processor`, `videos.service.ts renderDescription()` + +### RenderEngineService +**Location:** `backend/src/shared/render-engine/render-engine.service.ts` + +Pure computation. Takes `RenderInput`, returns `{ rendered: string, hash: string }`. + +**Used directly by:** `video-configs.service.ts renderPreview()`, `templates.service.ts renderPreview()` — these callers already have all data fetched. + +## Block Rendering by Type + +| Block Type | Rendering Behavior | +|---|---| +| `STATIC` | Raw content output, zero token substitution | +| `VARIABLE` | Resolves `{custom_var}`, `{video.*}`, `{collab.*}` tokens | +| `CAMPAIGN` | Auto-appended at end when `campaign.startAt ≤ now ≤ campaign.endAt` | +| `COLLABORATOR` | Cloned once per assigned collaborator, joined with `\n\n` (or `\n` if compact) | +| `CONDITIONAL` | Evaluates `condition` JSON against video data | +| `GLOBAL`, `REPEATABLE` | Deprecated; render as VARIABLE | + +## Freetext Entries + +`blockOrder` may contain IDs prefixed `freetext:` (e.g. `freetext:abc123`). These have no corresponding `DescriptionBlock` row. Their content comes entirely from `blockOverrides[id].content`. The `compact` flag for freetext entries also comes from `blockOverrides[id].compact`. + +## Token Resolution + +Resolution happens in three distinct passes per block, applied sequentially: + +1. **Custom variable pass** — `resolveVariables(content, effectiveVars)` replaces `{custom_token}` placeholders. `effectiveVars` is built by merging team variables and video-level values: `{ ...teamVariables, ...variableValues }`. **Video-level values win on any name collision** — last-write-wins from the spread. System tokens (`video.*`, `collab.*`) are explicitly skipped here. +2. **Video token pass** — `resolveVideoVars()` replaces `{video.*}` tokens using the video's own fields. +3. **Collaborator token pass** — `resolveCollaborators()` or `expandCollaboratorBlock()` replaces `{collab.*}` tokens. + +**Effective priority (highest → lowest):** +1. System tokens (`{video.*}`, `{collab.*}`) — always win; cannot be shadowed by any variable name +2. Video-level variables (`VideoConfig.variableValues`) — override team variables on conflict +3. Team variables (`TeamVariable` table) — baseline for custom tokens + +Because system tokens are skipped in pass 1 and resolved in dedicated passes afterwards, naming a team or video variable `video.title` has no effect — it is silently ignored and the actual video title is substituted instead. + +`DescriptionBlock.variableDefinitions` declares which tokens a block expects but is never read by the render engine — it is metadata only (see Backlog). + +Any token in `SYSTEM_VARIABLE_TOKENS` set is **skipped** by `resolveVariables()` and handled by its dedicated resolver. If you add a new `{video.*}` or `{collab.*}` token, it must be registered in the system variables registry. + +## System Variable Tokens + +**Video tokens:** + +| Token | Resolves to | +|---|---| +| `{video.title}` | Video title | +| `{video.tags}` | Comma-separated tag list | +| `{video.category}` | YouTube category name (e.g. `Gaming`). Falls back to the raw numeric ID if the category is not in the known map. There is no `{video.categoryId}` token. | +| `{video.scheduledAt}` | Scheduled publish date (date token — supports `\|format`) | +| `{video.recordingDate}` | Recording date (date token — supports `\|format`) | +| `{video.gameTitle}` | Game title field | +| `{video.language}` | Default language code | +| `{video.playlists}` | Comma-separated list of playlist **titles** (e.g. `Gaming, Tutorials`) | +| `{video.playlistLinks}` | Comma-separated list of full YouTube playlist **URLs** (e.g. `https://www.youtube.com/playlist?list=PLxxx`) | + +**Collaborator tokens:** +`{collab.name}`, `{collab.youtube}`, `{collab.twitch}`, `{collab.instagram}`, `{collab.tiktok}`, `{collab.twitter}`, `{collab.bluesky}`, `{collab.discord}`, `{collab.aliases}`, `{collab.notes}` + +Note: `{collab.youtube}` resolves to the full URL (`https://www.youtube.com/@handle`), not just the handle. + +## Multi-Collaborator Rendering + +Two different behaviors apply depending on block type: + +### In VARIABLE, CONDITIONAL, CAMPAIGN, and freetext blocks +`{collab.*}` tokens resolve against the **first** collaborator in `Video.collaboratorIds` whose record is found in the loaded collaborator list. If no collaborator is assigned, all `{collab.*}` tokens are left unreplaced. + +This means if a video has three collaborators, `{collab.name}` in a VARIABLE block only produces the first collaborator's name — not all three. + +### In COLLABORATOR blocks +The block's content template is expanded **once per assigned collaborator**, in the order they appear in `collaboratorIds`. Each expansion resolves all `{collab.*}` tokens against that specific collaborator. The resulting strings are joined with `\n\n` (double newline), or `\n` if the block has `compact: true`. + +**Example** — with two collaborators (Alice, Bob) and a COLLABORATOR block containing: +``` +🎮 {collab.name} — {collab.youtube} +``` +Renders as: +``` +🎮 Alice — https://www.youtube.com/@alice + +🎮 Bob — https://www.youtube.com/@bob +``` + +If a collaborator has no value for a given token (e.g. no `youtubeLink`), that token resolves to an empty string. + +## Hash Computation + +`hashMetadata()` in `shared/render-engine/hash.ts` hashes these fields: `title`, `description`, `tags`, `categoryId`, `privacyStatus`, `defaultLanguage`, `defaultAudioLanguage`, `selfDeclaredMadeForKids`, `embeddable`, `license`, `recordingDate`. + +The hash is stored as `Video.lastSyncedHash` after a successful YouTube push. `hasPendingChanges` is computed by comparing current state hash against `lastSyncedHash`. + +## Date Format Override Syntax + +Date tokens support an inline format override using `|` as a separator: + +``` +{video.scheduledAt|DD.MM.YYYY} +{video.recordingDate|MMMM D, YYYY} +``` + +**Only date tokens support this.** The two date tokens are `video.scheduledAt` and `video.recordingDate`. All other `video.*` and `collab.*` tokens are non-date (`kind: 'simple'`) — the regex captures the `|format` portion for all tokens but non-date resolvers ignore it. + +**Format string tokens** (custom implementation, not strftime or moment.js): + +| Token | Output | +|---|---| +| `YYYY` | 4-digit year (e.g. `2024`) | +| `YY` | 2-digit year (e.g. `24`) | +| `MMMM` | Full month name (e.g. `January`) | +| `MMM` | Short month name (e.g. `Jan`) | +| `MM` | Zero-padded month (e.g. `01`) | +| `M` | Month without padding (e.g. `1`) | +| `DD` | Zero-padded day (e.g. `05`) | +| `D` | Day without padding (e.g. `5`) | + +**Priority:** inline token format → team `dateFormat` setting → `YYYY-MM-DD` (hard default). + +**Invalid format strings** are silently passed through as-is. Any character or sequence not matching a known token is output verbatim — no error, no warning. + +**Null date:** If the date field is null or undefined (e.g. `video.recordingDate` not set), the token resolves to an empty string regardless of format. + +## Date Formatting Implementation + +`applyDateFormat()` in `render-engine.service.ts` uses a single-pass regex replacement to avoid re-substitution bugs. Do not convert to chained `.replace()` calls. + +## Related + +- [[02 - Backend]] +- [[02 - Description Engine]] (features) diff --git a/documentation/YouTube Studio Flow/02 - Features/01 - Video Management.md b/documentation/YouTube Studio Flow/02 - Features/01 - Video Management.md new file mode 100644 index 0000000..b7efdf6 --- /dev/null +++ b/documentation/YouTube Studio Flow/02 - Features/01 - Video Management.md @@ -0,0 +1,56 @@ +# Video Management + +## User Perspective +The Videos page (`/videos`) is the main workspace. It displays the team's full video library in a paginated, sortable table. Users can filter by status, search by title, switch between predefined tabs, and click any video to open the editor. + +### Video List Features +- **Tabs**: All Content, Published, Private, Scheduled, Conflicts, Push Pending, Lint Issues, Deleted (conditionally shown) +- **Columns**: YouTube ID, Title, Privacy Status, Published Date, Lint Status, Playlists, Sync Status +- **Sorting**: Click column headers +- **Pagination**: Page size selector + prev/next +- **Search**: Title search via URL param `search=` +- **Middle-click**: Opens video in background tab (native anchor overlay) +- **Lint status badges**: OK (no badge), WARNING (amber), ERROR (pink/red) +- **Sync status badges**: In sync (green), Push pending (amber), Conflict (red) + +### Video Editor Features +The video editor (`/videos/[id]`) provides: +- **Combined header**: thumbnail, YouTube/Studio links, meta info (ID, date, privacy, sync status), lint badge, action buttons +- **Sortable two-column layout**: sections (Basic Info, Language, Audience & Content, Playlists, Description) can be dragged between columns. Layout persists to user preferences. +- **Basic Info**: title, tags (chip input), privacy status, category, scheduled publish date, recording date, game title +- **Language**: title/description language, video language +- **Audience & Content**: Made for Kids toggle, Allow Embedding toggle, License selector +- **Playlists**: add/remove from channel playlists via search dropdown +- **Description**: description block config editor (see [[02 - Description Engine]]) +- **Actions**: Apply Template, Refresh from YouTube, Push to YouTube, Save Changes +- **Diff view**: "See what changed" shows field-level diff against last synced YouTube state + +### Game Title Field + +`gameTitle` is a **custom metadata field** — it is not a YouTube API field and is never populated during channel import. Users enter it manually in the video editor's Basic Info section. + +It serves two purposes: +1. **Token `{video.gameTitle}`** — resolves in description blocks, allowing game name to appear in descriptions automatically. +2. **Canva search link** — when `Team.showCanvaLink` is enabled in team settings, the video editor shows a "Search Canva" link that opens `https://www.canva.com/search?q={gameTitle}`, helping creators find thumbnail templates for their game. + +It can also be set as a template default via `Template.videoFields.gameTitle`, so assigning a template pre-fills the game name. + +## Developer Perspective + +### Backend +- **Module**: `backend/src/modules/videos/` +- **Key service methods**: `findAll(query, teamId)`, `findOne(id)`, `update(id, dto)`, `syncVideo(id)`, `refreshFromYouTube(id)`, `renderDescription(id)` +- **Sync flow**: `POST /videos/:id/sync` enqueues a `youtube-sync` BullMQ job. The processor renders description, checks hash, calls YouTube API, updates `lastSyncedHash` and `renderedDescription`. +- **`hasPendingChanges`**: Computed in `findAll` by comparing `hashMetadata(currentFields)` against `lastSyncedHash`. If `lastSyncedHash` is null, falls back to comparing against YouTube baseline using `youtubeDescription`. +- **Filters** (`GET /videos`): `search`, `lintStatus`, `privacyStatus`, `scheduled`, `notScheduled`, `remoteConflict`, `pendingSync`, `deletedOnYouTube`, `hasLintIssues` (matches ERROR or WARNING) + +### Frontend +- **Pages**: `frontend/src/app/(dashboard)/videos/page.tsx` (list) and `videos/[id]/page.tsx` (editor) +- **Table component**: `VideoTable.tsx` uses TanStack Table v8 +- **URL state**: Tab, page, sort, and search are persisted in URL params +- **Column layout persistence**: Saved to `User.preferences` via `PATCH /users/me/preferences` using keys `videoEditLeftCol` and `videoEditRightCol` + +## Related +- [[02 - Description Engine]] +- [[03 - Metadata Linting]] +- [[02 - Videos API]] diff --git a/documentation/YouTube Studio Flow/02 - Features/02 - Description Engine.md b/documentation/YouTube Studio Flow/02 - Features/02 - Description Engine.md new file mode 100644 index 0000000..2ba5633 --- /dev/null +++ b/documentation/YouTube Studio Flow/02 - Features/02 - Description Engine.md @@ -0,0 +1,88 @@ +# Description Engine + +## User Perspective +The description engine generates video descriptions from reusable building blocks. Instead of writing descriptions manually for each video, users define blocks (intro, CTA, social links, sponsor copy) and assemble them into templates. Each video can then use a template as its starting point, overriding individual blocks or variables as needed. + +### Blocks (`/blocks`) +A block is a named, reusable piece of description content. Types: +- **Static**: Plain text, rendered as-is. No token substitution. Suitable for boilerplate that never changes. +- **Variable**: Text with `{token}` placeholders. Tokens are resolved from team variables, video fields, or collaborator data. +- **Campaign**: Like Variable but linked to a Campaign. Auto-included in descriptions during the campaign's date window — no manual placement needed. +- **Collaborator**: Expanded once per assigned collaborator. The block's content is used as a template, cloned for each collaborator in `VideoConfig.collaboratorIds`, with `{collab.*}` tokens resolved per-collaborator. Clones are joined with a blank line (or single newline if compact). +- **Conditional**: Shown only when its condition evaluates to true. + +Blocks have a **compact** toggle: when compact is off, a blank line is inserted before the block's content during rendering. + +### Templates (`/templates`) +A template defines the default block assembly for a type of video. It contains: +- **Default block order**: which blocks appear and in what sequence +- **Default variable values**: pre-filled values for variable tokens +- **Video fields**: default metadata values (privacy, category, tags, etc.) applied when assigned +- **Rules**: required links for lint checking + +Applying a template to a video copies the block order, variable values, and optionally the video field defaults. + +### Variable Tokens +In Variable-type blocks, `{token}` placeholders are replaced at render time: +- `{video.title}`, `{video.tags}`, `{video.category}`, `{video.gameTitle}`, `{video.language}` — from the video record +- `{video.scheduledAt}`, `{video.recordingDate}` — dates; support inline format override (see below) +- `{video.playlists}` — comma-separated list of playlist **titles** the video belongs to +- `{video.playlistLinks}` — comma-separated list of full YouTube playlist **URLs** the video belongs to +- `{collab.name}`, `{collab.youtube}`, `{collab.twitch}`, `{collab.instagram}`, `{collab.tiktok}`, `{collab.twitter}`, `{collab.bluesky}`, `{collab.discord}`, `{collab.aliases}`, `{collab.notes}` — from the **first** assigned collaborator only (see below). Note: `{collab.youtube}` resolves to the full URL (`https://www.youtube.com/@handle`), not just the handle. +- `{my_variable}` — from team-level variables or video-level variable value overrides + +### Collaborator Token Behavior with Multiple Collaborators + +**In VARIABLE, CONDITIONAL, CAMPAIGN, and freetext blocks:** `{collab.*}` tokens always resolve to the **first** collaborator in the video's `collaboratorIds` list. If a video has multiple collaborators, tokens in non-COLLABORATOR blocks only reflect collaborator #1. + +**In COLLABORATOR blocks:** the block is cloned once per collaborator, each clone resolved against that collaborator's data, then joined with a blank line. This is the correct way to list multiple collaborators — one COLLABORATOR block produces one entry per person. + +**Practical rule:** use a COLLABORATOR block whenever the content should repeat for each collaborator. Use `{collab.*}` tokens in other block types only when you have exactly one collaborator, or you intentionally want only the first. + +### Date Token Format Overrides + +Date tokens (`{video.scheduledAt}` and `{video.recordingDate}`) support an inline format string using `|` as a separator: + +``` +{video.scheduledAt|DD.MM.YYYY} → e.g. 05.03.2024 +{video.recordingDate|MMMM D, YYYY} → e.g. March 5, 2024 +``` + +Available format tokens: `YYYY` (4-digit year), `YY` (2-digit), `MMMM` (full month), `MMM` (short month), `MM` (zero-padded month), `M` (month), `DD` (zero-padded day), `D` (day). + +Without an inline override, the team's date format setting is used. If no team format is set, the default is `YYYY-MM-DD`. If the date field is not set on the video, the token resolves to an empty string. + +No other tokens support the `|format` syntax — it is silently ignored on non-date tokens. + +### Freetext Entries +In the video editor, users can add freetext sections directly in the block order without creating a named block. These appear as free-form text areas in the editor and are stored with IDs prefixed `freetext:`. + +## Developer Perspective + +### Render Path +1. `VideoRenderService.render(videoId)` fetches all data +2. Builds ordered list of blocks from `VideoConfig.blockOrder` +3. Appends active CAMPAIGN blocks at the end (regardless of order) +4. For each block: applies `blockOverrides`, resolves tokens +5. Joins blocks with blank lines (unless `compact: true`) +6. Returns `{ rendered: string, hash: string }` + +### Key Files +- `backend/src/shared/render-engine/video-render.service.ts` — data fetching + orchestration +- `backend/src/shared/render-engine/render-engine.service.ts` — pure rendering logic +- `backend/src/shared/system-variables/system-variables.registry.ts` — system token registry + +### Adding a New System Token +1. Add the token to `SYSTEM_VARIABLES[]` in `system-variables.registry.ts` +2. Add it to `SYSTEM_VARIABLE_TOKENS` Set +3. Implement resolution in the appropriate resolver in `RenderEngineService` +4. Without step 2, `resolveVariables()` will attempt to resolve it as a team/video variable and fail silently + +### Block Versioning +`DescriptionBlock` has a `BlockVersion` history table. Every content change creates a new version snapshot. Version number increments on each save. + +## Related +- [[07 - Render Engine]] (architecture) +- [[03 - Blocks API]] +- [[04 - Templates API]] +- [[06 - Collaborators]] diff --git a/documentation/YouTube Studio Flow/02 - Features/03 - Metadata Linting.md b/documentation/YouTube Studio Flow/02 - Features/03 - Metadata Linting.md new file mode 100644 index 0000000..b920e1c --- /dev/null +++ b/documentation/YouTube Studio Flow/02 - Features/03 - Metadata Linting.md @@ -0,0 +1,65 @@ +# Metadata Linting + +## User Perspective +The linting feature automatically checks video metadata and descriptions for quality issues. Lint results appear as badges on the video list and as a collapsible section in the video editor header. + +### Lint Page (`/linting`) +Shows all open (unresolved) lint results across the team. Filter by severity or rule code. Results can be individually resolved (marking them as acknowledged). Navigating to the linting page automatically triggers a background heal that corrects any stale `lintStatus` values on video records. + +### Lint Status on Videos +Videos show one of three statuses: +- **OK** — no open lint issues +- **WARNING** — at least one warning, no errors +- **ERROR** — at least one error + +The "Lint Issues" tab on the Videos page shows all videos with WARNING or ERROR status. + +### Resolving Issues +Resolving a lint result marks it as `resolvedAt = now()`. It will reappear on the next lint run if the underlying issue is not fixed. Bulk-resolve is supported. + +## Developer Perspective + +### Rules + +All rules implement the `LintRule` interface: `{ code: string, severity: LintSeverity, check(video): LintIssue | null }`. + +`LintSeverity` has three values: `ERROR`, `WARNING`, and `INFO`. No current rule uses `INFO` — it is reserved for future informational hints. **Important:** `computeStatus()` only checks for `ERROR` and `WARNING` when computing `Video.lintStatus`. A video with only `INFO` results will have `lintStatus: OK`. This is intentional — INFO is non-actionable and should not surface as a problem on the video list. + +| Rule Code | Severity | What it checks | +|---|---|---| +| `TITLE_WEAK` | WARNING | Title < 20 chars or contains generic words (video, test, untitled, new video, upload) | +| `TITLE_TOO_LONG` | WARNING | Title > 100 characters | +| `DESC_MISSING_CTA` | WARNING | Description lacks CTA keywords (subscribe, abonnieren, follow, like, comment, cta) | +| `DESC_MISSING_CHAPTERS` | WARNING | Description has fewer than 2 timestamp patterns (`\d{1,2}:\d{2}`) | +| `DESC_EMPTY_PLACEHOLDER` | ERROR | Description contains unresolved `{placeholder}` patterns | +| `DESC_DUPLICATE_HASHTAG` | WARNING | Description has duplicate hashtags (case-insensitive) | +| `DESC_REQUIRED_LINK_MISSING` | ERROR | Description missing a required link defined in `template.rules.requiredLinks` | +| `DESC_OUTDATED_SPONSOR_COPY` | ERROR | A CAMPAIGN block references an expired or inactive campaign | +| `REMOTE_CONFLICT` | ERROR | `video.remoteConflict` is true — YouTube-side metadata changed since last sync | + +### Lint Execution Flow +1. `LintProcessor` receives `{ videoId }` from the `lint` queue +2. Calls `LintingService.lintVideo(videoId)` +3. Fetches video with config, template, existing lint results, and team's `disabledLintRules` +4. Filters rules: removes any whose `code` is in `disabledLintRules` +5. Runs each active rule's `check()` method +6. Wraps in a transaction: deletes all unresolved results, creates new ones, updates `Video.lintStatus` + +### Disabling Rules +Team admins can disable specific rules in Settings. When a rule is disabled: +1. Existing unresolved `LintResult` rows for that rule are deleted +2. `Video.lintStatus` is recomputed for all affected videos +3. Future lint runs skip that rule + +### Stale lintStatus +`Video.lintStatus` is a denormalized cache. It can become stale if `LintResult` rows are deleted outside of `lintVideo()`. The heal endpoint `POST /lint/team/recompute-status` corrects all stale statuses in one query. This endpoint is called automatically when the linting page loads. + +### Adding a New Lint Rule +1. Create `backend/src/modules/linting/rules/your-rule.rule.ts` implementing `LintRule` +2. Import and instantiate it in `LintingService.rules[]` array +3. The rule will run automatically on all subsequent lint jobs + +## Related +- [[03 - Linting API]] +- [[01 - Video Management]] +- [[13 - Team Settings]] diff --git a/documentation/YouTube Studio Flow/02 - Features/04 - Bulk Operations.md b/documentation/YouTube Studio Flow/02 - Features/04 - Bulk Operations.md new file mode 100644 index 0000000..43ad7f9 --- /dev/null +++ b/documentation/YouTube Studio Flow/02 - Features/04 - Bulk Operations.md @@ -0,0 +1,42 @@ +# Bulk Operations + +## User Perspective +Bulk operations allow users to apply a metadata change to many videos at once — without pushing to YouTube individually. Changes go through a **preview → confirm** flow. + +### Supported Bulk Actions +- **Set Privacy Status** — change privacy on all/selected videos +- **Assign Template** — apply a template to multiple videos +- **Add Tags** — append tags without replacing existing ones +- **Remove Tags** — remove specific tags from videos +- **Search & Replace in Title** — find and replace text in video titles + +### Push Pending Bulk Sync +The "Push Pending" feature batch-syncs all videos with pending changes to YouTube. Accessible via the Push Pending tab. Users preview field-level diffs before confirming. + +### Rollback +Completed bulk jobs can be rolled back from the Bulk Jobs page (`/bulk-jobs`). Rollback restores the `beforeSnapshot` values for each affected video. + +### Bulk Job Lifecycle +`PENDING → DRY_RUN → CONFIRMED → RUNNING → DONE` (or `FAILED` / `ROLLED_BACK`) + +## Developer Perspective + +### Backend +- **Module**: `backend/src/modules/bulk-jobs/` +- **Endpoints**: `GET /bulk-jobs`, `GET /bulk-jobs/:id`, `POST /bulk-jobs/:id/rollback`, `GET /bulk-jobs/push-pending/preview`, `POST /bulk-jobs/push-pending` +- **Header bulk change**: The Header component's "Bulk Change" modal calls `POST /bulk-jobs/preview` (via `bulkPreview()` in api.ts) and `POST /bulk-jobs/apply` + +### Job Processing +The `bulk-metadata.processor.ts` processor: +1. Loads `BulkJob` with all `BulkJobItem` records +2. For each item: saves `beforeSnapshot`, applies change, saves `afterSnapshot` +3. Updates `successCount` / `errorCount` on job +4. On completion: sets `status = DONE` and `completedAt` + +### Data Model +- `BulkJob` — one per operation. Has `type`, `filterSnapshot`, `rollbackData` +- `BulkJobItem` — one per video. Has `beforeSnapshot`, `afterSnapshot`, `status` + +## Related +- [[11 - Bulk Jobs API]] +- [[01 - Video Management]] diff --git a/documentation/YouTube Studio Flow/02 - Features/05 - Playlists.md b/documentation/YouTube Studio Flow/02 - Features/05 - Playlists.md new file mode 100644 index 0000000..55d0022 --- /dev/null +++ b/documentation/YouTube Studio Flow/02 - Features/05 - Playlists.md @@ -0,0 +1,26 @@ +# Playlists + +## User Perspective +Playlists are synced from YouTube and can be managed per-video in the video editor. Users can add a video to one or more playlists, remove it from playlists, and sync the playlist list from YouTube to pick up newly created playlists. + +### Video Editor — Playlists Section +- Shows current playlists as chips with remove buttons +- Search box filters available playlists from the channel +- "Sync playlists" button refreshes the playlist list from YouTube + +## Developer Perspective + +### Backend +- **Module**: `backend/src/modules/playlists/` +- **Endpoints**: `GET /playlists/channel/:channelId`, `GET /playlists/video/:videoId`, `POST /playlists/channel/:channelId/sync`, `POST /playlists/video/:videoId/:playlistId`, `DELETE /playlists/video/:videoId/:playlistId` + +### Data Model +- `Playlist` — one per YouTube playlist, scoped to `channelId` +- `VideoPlaylist` — join table with `videoId`, `playlistId`, `position` + +### Quota Usage +Adding a video to a playlist costs YouTube API quota. The `youtube-sync` processor handles this when syncing. + +## Related +- [[07 - Playlists API]] +- [[01 - Video Management]] diff --git a/documentation/YouTube Studio Flow/02 - Features/06 - Collaborators.md b/documentation/YouTube Studio Flow/02 - Features/06 - Collaborators.md new file mode 100644 index 0000000..56d3909 --- /dev/null +++ b/documentation/YouTube Studio Flow/02 - Features/06 - Collaborators.md @@ -0,0 +1,41 @@ +# Collaborators + +## User Perspective +Collaborators are people who appear in videos (guests, co-hosts, sponsors). Defining them centrally allows description templates to include their links automatically via tokens. + +### Collaborator Fields +- Name (required) +- YouTube link (full URL) +- Twitch, Instagram, TikTok, Twitter/X, Bluesky links +- Discord handle +- Aliases (alternate names/handles) +- Notes + +### Usage in Descriptions +In Variable-type blocks, use tokens like `{collab.youtube}`, `{collab.name}` etc. These are resolved based on which collaborators are assigned to the video via `Video.collaboratorIds`. + +## Developer Perspective + +### Tracking +Collaborators are assigned to videos via `Video.collaboratorIds` — a JSON string array on the `Video` row. There is no separate join table. This single field drives both description rendering and the `collaboratorId` filter on the video list. Use the `array_contains` Prisma operator when querying it. + +### Token Mapping +| Token | Resolves to | +|---|---| +| `{collab.name}` | Collaborator.name | +| `{collab.youtube}` | Full URL: `https://www.youtube.com/@handle` (not just handle) | +| `{collab.twitch}` | Collaborator.twitchLink | +| `{collab.instagram}` | Collaborator.instagramLink | +| `{collab.tiktok}` | Collaborator.tiktokLink | +| `{collab.twitter}` | Collaborator.twitterLink | +| `{collab.bluesky}` | Collaborator.blueskyLink | +| `{collab.discord}` | Collaborator.discordHandle | +| `{collab.aliases}` | Joined alias list | +| `{collab.notes}` | Collaborator.notes | + +**Note:** `{collab.handle}` is a deprecated token. It was renamed to `{collab.youtube}`. Blocks still using `{collab.handle}` will not resolve. + +## Related +- [[02 - Description Engine]] +- [[05 - Collaborators API]] +- [[07 - Render Engine]] (architecture) diff --git a/documentation/YouTube Studio Flow/02 - Features/07 - Saved Views.md b/documentation/YouTube Studio Flow/02 - Features/07 - Saved Views.md new file mode 100644 index 0000000..5354239 --- /dev/null +++ b/documentation/YouTube Studio Flow/02 - Features/07 - Saved Views.md @@ -0,0 +1,41 @@ +# Saved Views + +## User Perspective +Saved Views are named filter presets for the video list. Users can create a view from their current filter state and quickly reapply it later. Views can optionally be pinned as tabs in the video list tab bar. + +### Creating a View +From the Videos page, apply filters, then click the save icon or the "+" in the tab bar. Give the view a name and optionally pin it as a tab. + +### Pinned Tabs +Pinned saved views appear in the tab bar after the system tabs. Clicking a tab applies that view's query. + +### Global vs Personal +- `isGlobal: true` — visible to all team members +- `isGlobal: false` — private to the owner + +## Developer Perspective + +### Data Model +``` +SavedView { + queryJson // filter state — mirrors VideosQuery + columnsJson // column visibility/order + sortJson // sort state + pinnedAsTab // show in tab bar + tabOrder // ordering among pinned tabs + isGlobal + ownerId +} +``` + +### Endpoints +- `GET /saved-views` — list all views for team +- `GET /saved-views/tabs` — pinned-as-tab views ordered by tabOrder +- `POST /saved-views` — create +- `PATCH /saved-views/:id` — update +- `DELETE /saved-views/:id` — delete (ADMIN+) +- `POST /saved-views/:id/execute` — returns matching video IDs + +## Related +- [[08 - Saved Views API]] +- [[01 - Video Management]] diff --git a/documentation/YouTube Studio Flow/02 - Features/08 - Campaigns.md b/documentation/YouTube Studio Flow/02 - Features/08 - Campaigns.md new file mode 100644 index 0000000..805746a --- /dev/null +++ b/documentation/YouTube Studio Flow/02 - Features/08 - Campaigns.md @@ -0,0 +1,35 @@ +# Campaigns + +## User Perspective +A Campaign represents a time-bounded promotional window (e.g. a sponsor deal running from June 1 to June 30). Description blocks can be linked to a campaign. While the campaign is active, those blocks are automatically appended to every video description — without the user needing to add them to each video's block order manually. + +### Campaign Fields +- Name +- Start date (`startAt`) +- End date (`endAt`, optional — ongoing campaigns have no end date) +- Status (active / inactive) +- Notes + +### Auto-inclusion +A CAMPAIGN block is auto-appended to the rendered description when: +- The block's linked campaign has `status = "active"` +- `campaign.startAt ≤ now ≤ campaign.endAt` (or no endAt) + +If a campaign expires (endAt in the past), any video with that campaign block will trigger the `DESC_OUTDATED_SPONSOR_COPY` lint error. + +## Developer Perspective + +### Backend +- **Module**: `backend/src/modules/campaigns/` +- CAMPAIGN blocks are fetched separately from `blockOrder` in `VideoRenderService` and appended at the end of the render output + +### API surface +`GET /campaigns` returns a read-only listing of all campaigns for the team. Create, update, and delete are not exposed through the REST API — campaign management currently requires direct database access. See [[15 - Campaigns API]] and the **Campaign CRUD API** entry in [[01 - Technical Debt and Future Work]]. + +### Lint Integration +`DescOutdatedSponsorRule` checks whether any CAMPAIGN blocks in `VideoConfig.blockOrder` reference an expired or inactive campaign. This produces a `DESC_OUTDATED_SPONSOR_COPY` ERROR. + +## Related +- [[02 - Description Engine]] +- [[03 - Metadata Linting]] +- [[15 - Campaigns API]] diff --git a/documentation/YouTube Studio Flow/02 - Features/09 - Calendar.md b/documentation/YouTube Studio Flow/02 - Features/09 - Calendar.md new file mode 100644 index 0000000..c9505aa --- /dev/null +++ b/documentation/YouTube Studio Flow/02 - Features/09 - Calendar.md @@ -0,0 +1,24 @@ +# Calendar + +## User Perspective +The Content Calendar (`/calendar`) visualizes scheduled and published videos on a calendar grid. The UI exposes two views: **Month** and **Agenda** (next 30 days). A week view exists at the API level but is not surfaced in the frontend. + +Each entry shows: +- Video title +- Scheduled/published date +- Template name +- Assigned collaborators +- Lint status +- Privacy status + +## Developer Perspective + +### Backend +- **Module**: `backend/src/modules/calendar/` +- **Endpoint**: `GET /calendar?view=month|week|agenda&date=YYYY-MM` +- Returns videos where `scheduledAt` OR `publishedAt` falls within the computed date range +- For `agenda` view: next 30 days from the given date +- For `week` view: the week containing day 1 of the given month. **Known limitation:** the `date` param is `YYYY-MM` (no day), so the week view always anchors to the first of the month — arbitrary week navigation is not possible at the API level. The frontend does not expose this view. + +## Related +- [[12 - Calendar API]] diff --git a/documentation/YouTube Studio Flow/02 - Features/10 - Import Export.md b/documentation/YouTube Studio Flow/02 - Features/10 - Import Export.md new file mode 100644 index 0000000..046c77a --- /dev/null +++ b/documentation/YouTube Studio Flow/02 - Features/10 - Import Export.md @@ -0,0 +1,33 @@ +# Import / Export + +## User Perspective +The Import/Export page (`/io`) allows migrating video metadata in bulk. + +### CSV Import +1. Upload a CSV file +2. Map CSV columns to video fields (title, tags, description, etc.) +3. Review the validation report — shows errors and warnings per row +4. Commit to apply changes + +### JSON Workspace Import +Import a full workspace export (blocks, templates, variables, collaborators) from another team or environment. + +### Export +Export video metadata or workspace configuration as CSV or JSON. + +## Developer Perspective + +### Backend +- **Imports module**: `backend/src/modules/imports/` + - `POST /imports/csv/preview` — validates CSV, creates `ImportJob` with `validationReport` + - `POST /imports/csv/commit` — applies the import + - `POST /imports/json/preview` — validates JSON workspace payload + - `POST /imports/json/commit` — applies the workspace import +- **Exports module**: `backend/src/modules/exports/` + +### Data Models +- `ImportJob` — tracks import with validation report and commit status +- `ExportJob` — tracks export with file reference + +## Related +- [[13 - Import Export API]] diff --git a/documentation/YouTube Studio Flow/02 - Features/11 - Quota Management.md b/documentation/YouTube Studio Flow/02 - Features/11 - Quota Management.md new file mode 100644 index 0000000..302a8fc --- /dev/null +++ b/documentation/YouTube Studio Flow/02 - Features/11 - Quota Management.md @@ -0,0 +1,31 @@ +# Quota Management + +## User Perspective +The Quota History page (`/quota-history`) shows YouTube API quota usage over time. Each sync or playlist operation is logged. Entries are grouped by action (e.g. all API calls for a single video sync appear as one row) with the total quota cost shown. + +The display shows: +- Video title + YouTube video ID +- Action type (video_sync, playlist_add, etc.) +- Units consumed +- Time of operation +- Expandable detail rows + +## Developer Perspective + +### Quota Budget +YouTube imposes a **10,000 unit daily quota** that resets at midnight Pacific Time. Key costs: +- `videos.update` — 50 units +- `videos.list` — 1 unit +- Playlist operations — varies + +### QuotaService +- `canSpend(units: number)` — returns true if spending is allowed +- `spend(units, operation, meta)` — logs usage to `QuotaLog` + +All YouTube API writes must call `canSpend()` before proceeding and `spend()` after. This is enforced in the `youtube-sync` processor. + +### QuotaLog Fields +`datePt` stores the date in Pacific Time (for correct daily boundary). `actionId` groups related log entries from one user action (e.g. one video sync). The frontend clusters entries with the same `videoId` within 60 seconds for display. + +## Related +- [[10 - Quota API]] diff --git a/documentation/YouTube Studio Flow/02 - Features/12 - Audit Log.md b/documentation/YouTube Studio Flow/02 - Features/12 - Audit Log.md new file mode 100644 index 0000000..b0065ba --- /dev/null +++ b/documentation/YouTube Studio Flow/02 - Features/12 - Audit Log.md @@ -0,0 +1,34 @@ +# Audit Log + +## User Perspective +The Change History page (`/audit`) shows a chronological log of all user-initiated changes to the workspace. Useful for tracking who changed what and when. + +Each entry shows: actor, entity type, entity ID, action (create/update/delete), timestamp, and before/after JSON snapshots (expandable). + +## Developer Perspective + +### AuditService +```typescript +AuditService.log( + actorId: string, + entityType: string, // 'Video', 'Template', 'DescriptionBlock', etc. + entityId: string, + action: string, // 'create', 'update', 'delete' + before?: object, + after?: object +) +``` + +### When to Log +Every user-facing mutation (create/update/delete) must call `AuditService.log()`. Background/system operations (queue processors, channel import) do NOT log. + +### Adding Audit Logging to a Module +1. Import `AuditModule` in the module's `@Module({ imports: [...] })` array +2. Inject `AuditService` in the service constructor +3. Call `AuditService.log()` in each mutation method + +### Tracked Entities +`Video`, `VideoConfig`, `DescriptionBlock`, `Template`, `Collaborator`, `TeamVariable`, `SavedView`, `TeamMember` + +## Related +- [[02 - Backend]] (architecture) diff --git a/documentation/YouTube Studio Flow/02 - Features/13 - Team Settings.md b/documentation/YouTube Studio Flow/02 - Features/13 - Team Settings.md new file mode 100644 index 0000000..a729253 --- /dev/null +++ b/documentation/YouTube Studio Flow/02 - Features/13 - Team Settings.md @@ -0,0 +1,56 @@ +# Team Settings + +## User Perspective +The Settings page (`/settings`) covers: +- **Team members**: view, invite, change role, remove +- **Connected channels**: view connected YouTube channels +- **Render settings** (ADMIN+): + - Date format for date tokens (`{video.scheduledAt}`, `{video.recordingDate}`) + - Timezone + - Publishing schedule (weekly time slots for the "Next free slot" feature in the video editor) + - Show Canva link in video editor (for teams that use Canva for thumbnails) + - Disabled lint rules + - Show deleted videos in video list +- **Remote conflict detection** (ADMIN+): + - Enable scheduled conflict detection (opt-in per team) + - Videos per run (batch size) + - Only check videos older than N days + +## Developer Perspective + +### Team Settings Fields +| Field | Type | Description | +|---|---|---| +| `dateFormat` | `String?` | strftime-like format for date tokens | +| `timezone` | `String` | IANA timezone string, default "UTC" | +| `publishingSchedule` | `Json?` | Array of weekly time slots | +| `showCanvaLink` | `Boolean` | Shows Canva search link in video editor | +| `disabledLintRules` | `String[]` | Rule codes excluded from lint runs | +| `showDeletedVideos` | `Boolean` | Shows the Deleted tab in video list | +| `conflictDetectionEnabled` | `Boolean` | Opt-in for the scheduled remote-conflict sweep (default `false`) | +| `conflictDetectionBatchSize` | `Int` | Max videos checked per run (1–500, default 50) | +| `conflictDetectionMinAgeDays` | `Int` | Skip videos whose `lastSyncedAt` is within N days (≥0, default 7) | + +### Disabling Lint Rules (Side Effect) +When `disabledLintRules` is updated via `PATCH /teams/:teamId/settings`: +1. All unresolved `LintResult` rows for the newly disabled rules are deleted +2. `Video.lintStatus` is recomputed for all affected videos + +### Publishing Schedule +Used by the "Next free slot" button in the video editor's Basic Info section. The schedule defines which days/times are available for publishing. `GET /teams/:teamId/next-publish-slot?channelId=` returns the next available ISO datetime. + +The slot-finder walks candidate slots day by day and checks whether any existing `scheduledAt` on the channel falls within the **collision window**. Two hardcoded constants govern this behavior (`teams.service.ts`): + +| Constant | Value | Effect | +|---|---|---| +| Collision window | ±30 minutes | A slot is considered taken if any video on that channel is already scheduled within 30 minutes either side of it | +| Lookahead limit | 90 days | If no free slot is found within 90 days, the endpoint returns `{ slot: null }` | + +These values are not configurable per-team. The 30-minute collision window means teams that publish multiple videos per day with closely spaced slots may find legitimate adjacent slots blocked if a video is already scheduled in between. See the backlog. + +### Remote Conflict Detection Settings +The three `conflictDetection*` fields control the per-team share of the global sweep queued by `ConflictDetectionScheduler`. The sweep itself only runs if the operator has set `CONFLICT_DETECTION_ENABLED=true` on the worker (see [[02 - Environment Variables]]) — the team toggle alone does nothing without it. Validation in `TeamsService.updateSettings` enforces `1 ≤ batchSize ≤ 500` and `minAgeDays ≥ 0`. See [[05 - Queue System]] for how the processor uses these values, and [[02 - Videos API]] for how detected conflicts are resolved (`POST /videos/:id/accept-remote` vs `POST /videos/:id/sync`). + +## Related +- [[09 - Teams API]] +- [[03 - Metadata Linting]] diff --git a/documentation/YouTube Studio Flow/03 - API Reference/01 - Auth API.md b/documentation/YouTube Studio Flow/03 - API Reference/01 - Auth API.md new file mode 100644 index 0000000..3ad02f0 --- /dev/null +++ b/documentation/YouTube Studio Flow/03 - API Reference/01 - Auth API.md @@ -0,0 +1,129 @@ +# Auth API + +Base path: `/api/v1/auth` + +All endpoints require `Authorization: Bearer ` unless noted otherwise. + +Related: [[02 - Authentication]], [[09 - Teams API]] + +--- + +## Endpoints + +### GET /auth/google + +Initiates the Google OAuth flow. Redirects the browser to the Google consent screen. + +> No authentication required. + +--- + +### GET /auth/google/callback + +OAuth callback handler. Called by Google after the user grants consent. Sets JWT tokens and redirects to the frontend. + +> No authentication required. Handled entirely by the backend. + +--- + +### POST /auth/refresh + +Refreshes the access token using a valid refresh token. + +**Request body:** + +```json +{ "refreshToken": "string" } +``` + +**Response:** + +```json +{ + "accessToken": "string", + "refreshToken": "string" +} +``` + +--- + +### POST /auth/logout + +Invalidates the current refresh token. Requires authentication. + +**Response:** + +```json +{ "message": "Logged out" } +``` + +--- + +### GET /auth/me + +Returns the currently authenticated user. + +**Response:** + +```json +{ + "id": "cuid", + "email": "user@example.com", + "name": "User Name", + "teamId": "cuid", + "teamRole": "EDITOR" +} +``` + +**Possible `teamRole` values:** `OWNER`, `ADMIN`, `EDITOR`, `REVIEWER`, `READONLY` + +--- + +### POST /auth/switch-team + +Switch the active team context. Returns a new access token scoped to the requested team. The caller must be a member of the target team. + +**Request body:** + +```json +{ "teamId": "cuid" } +``` + +**Response:** + +```json +{ "accessToken": "string" } +``` + +Replace the stored access token with the returned one. All subsequent requests will be scoped to the new team. The refresh token is unchanged. + +> **Note:** The response does not include `teamRole`. The new role is encoded inside the JWT payload (`teamRole` claim). Clients that need the role without decoding the JWT must call `GET /users/me` after switching teams. See the backlog for a possible improvement. + +> **Frontend status:** No UI exists for team switching. This endpoint is only reachable via direct API call. Use `GET /teams/mine` to enumerate the teams available to switch to. + +--- + +### GET /users/me/preferences + +Returns the current user's preferences as a freeform JSON object. + +**Response:** `Record` + +--- + +### PATCH /users/me/preferences + +Merges new key/value pairs into the user's stored preferences. Existing keys not present in the request body are preserved. + +**Request body:** `Record` + +**Response:** Updated preferences object + +--- + +## Notes + +- Access tokens are short-lived JWTs signed with `JWT_SECRET`. +- Refresh tokens are stored encrypted and are invalidated on logout. +- YouTube OAuth tokens (for channel connections) are separate from user auth tokens and are AES-256 encrypted in the database using `TOKEN_ENCRYPTION_KEY`. If that key changes, all channel connections must be re-authenticated. +- See [[06 - Authentication]] for the full auth flow and guard usage. diff --git a/documentation/YouTube Studio Flow/03 - API Reference/02 - Videos API.md b/documentation/YouTube Studio Flow/03 - API Reference/02 - Videos API.md new file mode 100644 index 0000000..79950ac --- /dev/null +++ b/documentation/YouTube Studio Flow/03 - API Reference/02 - Videos API.md @@ -0,0 +1,160 @@ +# Videos API + +Base path: `/api/v1/videos` + +All endpoints require `Authorization: Bearer `. + +Related: [[01 - Video Management]], [[04 - Database Schema]], [[11 - Bulk Jobs API]], [[07 - Playlists API]] + +--- + +## GET /videos + +List videos for the current team with pagination, sorting, and filtering. + +**Query parameters:** + +| Param | Type | Description | +|---|---|---| +| `page` | number | Page number (default: `1`) | +| `pageSize` | number | Items per page (default: `20`) | +| `sortBy` | string | Field to sort by | +| `sortOrder` | `asc\|desc` | Sort direction | +| `search` | string | Title search (case-insensitive) | +| `lintStatus` | `OK\|WARNING\|ERROR` | Exact lint status match | +| `hasLintIssues` | boolean | Match `ERROR` or `WARNING` | +| `privacyStatus` | `PUBLIC\|PRIVATE\|UNLISTED` | Privacy filter | +| `scheduled` | boolean | Videos with a future `scheduledAt` | +| `notScheduled` | boolean | Videos without a `scheduledAt` value | +| `remoteConflict` | boolean | Videos with a remote conflict flag | +| `pendingSync` | boolean | Videos where `hasPendingChanges` is `true` | +| `deletedOnYouTube` | boolean | Videos with `youtubeDeletedAt` set | + +**Response:** + +```json +{ + "data": [ "VideoSummary[]" ], + "total": 142, + "page": 1, + "pageSize": 20 +} +``` + +`hasPendingChanges` is computed per video by comparing the current state hash against `lastSyncedHash`. See [[01 - System Overview]] for details on the sync status logic. + +--- + +## GET /videos/:id + +Get full video detail including lint results, playlists, and collaborators. + +**Response:** `VideoDetail` object + +--- + +## PATCH /videos/:id + +Update video metadata fields. + +**Request body** (all fields optional): + +```json +{ + "title": "string", + "tags": ["string"], + "privacyStatus": "PUBLIC|PRIVATE|UNLISTED", + "scheduledAt": "ISO datetime", + "categoryId": "string", + "selfDeclaredMadeForKids": false, + "embeddable": true, + "license": "youtube|creativeCommon", + "defaultLanguage": "en", + "defaultAudioLanguage": "en", + "recordingDate": "YYYY-MM-DD", + "gameTitle": "string", + "collaboratorIds": ["cuid"], + "templateId": "cuid" +} +``` + +**Notes:** +- `recordingDate` is not returned by YouTube during import and is always `null` after a channel sync. Setting it locally will correctly mark the video as push-pending. +- Providing `collaboratorIds` updates `Video.collaboratorIds` (a JSON string array on the `Video` row). This is the single field the render engine reads to resolve `{collab.*}` tokens. +- Providing `templateId` applies the template's block configuration to the video's `VideoConfig`. + +--- + +## POST /videos/:id/sync + +Enqueue a YouTube sync job for the video. Returns immediately; processing is asynchronous via BullMQ. + +The processor renders the description, computes a metadata hash, skips if unchanged, then pushes all editable fields to YouTube. After a successful push, `renderedDescription` and `lastSyncedHash` are updated on the video row. + +**Response:** + +```json +{ "queued": true } +``` + +**Cost:** 50 YouTube API quota units per successful push. See [[10 - Quota API]]. + +--- + +## POST /videos/:id/refresh + +Pull latest metadata from YouTube and update the local record. This is a synchronous operation. + +> Re-importing after making local changes will reset the `lastSyncedHash` baseline. + +**Response:** Updated `VideoDetail` + +--- + +## POST /videos/:id/accept-remote + +Resolve a detected remote conflict by adopting the pending remote snapshot as the new local state. Requires **EDITOR** role. **Zero YouTube API calls** — operates entirely on the snapshot captured by `detectConflict()` when the conflict was first detected. + +**Behavior:** +1. Copies `pendingRemoteSnapshot` fields into the live Video columns (`title`, `tags`, `categoryId`, `privacyStatus`, `defaultLanguage`, `defaultAudioLanguage`, `selfDeclaredMadeForKids`, `embeddable`, `license`, `recordingDate`) +2. Sets `youtubeDescription = renderedDescription = pendingRemoteDescription` +3. Promotes `pendingRemoteSnapshot` to `youtubeSnapshot` +4. Recomputes `lastSyncedHash` from the accepted state, sets `lastSyncedAt = now` +5. Clears `pendingRemoteSnapshot`, `pendingRemoteDescription`, `remoteConflict` +6. Writes an audit log entry with `action: 'accept-remote'` + +**Errors:** +- `400` if there is no `pendingRemoteSnapshot` to accept +- `404` if the video is not in the caller's team + +**Response:** Updated `Video` row. + +For the opposite direction ("keep local, overwrite remote"), just call `POST /videos/:id/sync` — the sync processor overwrites remote and clears `remoteConflict` on success. + +--- + +## POST /videos/:id/render + +Render the video's description using the current `VideoConfig` and return the result without saving or pushing. + +**Response:** + +```json +{ "rendered": "string" } +``` + +--- + +## DELETE /videos/:id + +Soft-delete the video (marks as deleted locally). Does not delete the video from YouTube. + +--- + +## GET /youtube-sync/channels + +List connected YouTube channels for the current team. + +Base path: `/api/v1/youtube-sync/channels` + +**Response:** `Channel[]` diff --git a/documentation/YouTube Studio Flow/03 - API Reference/03 - Blocks API.md b/documentation/YouTube Studio Flow/03 - API Reference/03 - Blocks API.md new file mode 100644 index 0000000..57d9e49 --- /dev/null +++ b/documentation/YouTube Studio Flow/03 - API Reference/03 - Blocks API.md @@ -0,0 +1,97 @@ +# Description Blocks API + +Base path: `/api/v1/blocks` + +All endpoints require `Authorization: Bearer `. + +Related: [[02 - Description Engine]], [[04 - Templates API]], [[04 - Database Schema]] + +--- + +## GET /blocks + +List all description blocks for the current team. + +**Response:** `DescriptionBlock[]` + +--- + +## GET /blocks/:id + +Get a single block including its version history. + +**Response:** `DescriptionBlock` with `versions: BlockVersion[]` + +--- + +## POST /blocks + +Create a new description block. Requires `EDITOR` role. + +**Request body:** + +```json +{ + "name": "string", + "type": "STATIC|VARIABLE|CAMPAIGN|COLLABORATOR|CONDITIONAL", + "content": "string", + "language": "de", + "campaignId": "cuid", + "compact": false, + "tags": ["string"], + "variableDefinitions": [] +} +``` + +**Field notes:** + +| Field | Notes | +|---|---| +| `type` | See block type behavior below. `GLOBAL` and `REPEATABLE` are kept in the DB enum but are not exposed in the UI. | +| `campaignId` | Only applicable when `type` is `CAMPAIGN`. Links the block to a campaign date window. | +| `compact` | Block-level default for whether a blank line precedes the block in the rendered output. Can be overridden per video in `VideoConfig.blockOverrides`. | +| `variableDefinitions` | Metadata about custom variables used in the block's content. | + +**Block type behavior:** + +| Type | Render behavior | +|---|---| +| `STATIC` | Raw output, no token substitution | +| `VARIABLE` | Resolves `{custom_var}`, `{video.*}`, and `{collab.*}` tokens | +| `CAMPAIGN` | Auto-included at the end of the render when the linked campaign's date window is active (`startAt <= now <= endAt`), regardless of block order | +| `COLLABORATOR` | Resolved against assigned collaborators | +| `CONDITIONAL` | Rendered based on condition evaluation | + +See [[02 - Description Engine]] for full rendering pipeline details. + +--- + +## PATCH /blocks/:id + +Update a block. Creates a version snapshot before applying changes. Requires `EDITOR` role. + +**Request body:** Same fields as `POST /blocks`, all optional. + +--- + +## DELETE /blocks/:id + +Delete a block. Requires `ADMIN` role. + +Returns `409 Conflict` if the block is referenced in any `VideoConfig.blockOrder` or template's `defaultBlocks`. Deletion is blocked until all references are removed. The error message includes the count of affected video configs and templates. + +--- + +## GET /blocks/:id/versions + +List the version history for a block, ordered most-recent first. + +**Response:** `BlockVersion[]` + +--- + +## Notes + +- Free-text entries in a video's block order use IDs prefixed `freetext:` — no `DescriptionBlock` row exists for these. Their content comes entirely from `blockOverrides[id].content`. +- The `{collab.youtube}` token resolves to the full YouTube URL (e.g. `https://www.youtube.com/@handle`), not just the handle. See [[05 - Collaborators API]] for all available `collab.*` tokens. +- System variable tokens (`video.*`, `collab.*`) are registered in `shared/system-variables/system-variables.registry.ts`. New tokens must be added there or they will never resolve. diff --git a/documentation/YouTube Studio Flow/03 - API Reference/04 - Templates API.md b/documentation/YouTube Studio Flow/03 - API Reference/04 - Templates API.md new file mode 100644 index 0000000..d5c68c4 --- /dev/null +++ b/documentation/YouTube Studio Flow/03 - API Reference/04 - Templates API.md @@ -0,0 +1,125 @@ +# Templates API + +Base path: `/api/v1/templates` + +All endpoints require `Authorization: Bearer `. + +Related: [[02 - Description Engine]], [[03 - Blocks API]], [[02 - Videos API]] + +--- + +## GET /templates + +List all active templates for the current team. + +**Response:** `Template[]` + +--- + +## GET /templates/:id + +Get a single template by ID. + +**Response:** `Template` + +--- + +## POST /templates + +Create a new template. Requires `EDITOR` role. + +**Request body:** + +```json +{ + "name": "string", + "description": "string", + "defaultBlocks": ["blockId", "freetext:uuid"], + "defaultOverrides": {}, + "rules": { + "requiredLinks": ["https://..."] + }, + "variables": {}, + "videoFields": { + "privacyStatus": "PUBLIC", + "tags": ["tag1"] + } +} +``` + +**Field notes:** + +| Field | Notes | +|---|---| +| `description` | Optional human-readable description of the template's purpose. | +| `defaultBlocks` | Ordered list of block IDs. May include `freetext:uuid` entries for inline free-text entries. | +| `defaultOverrides` | Per-block overrides in the same shape as `VideoConfig.blockOverrides`. Stores free-text content keyed by `freetext:*` ID. | +| `rules` | Optional lint/validation rules applied when the template is in use, e.g. required links. | +| `variables` | Default variable values to seed into `VideoConfig.variableValues` when the template is applied. | +| `videoFields` | Video metadata fields to apply when the template is applied to a video (e.g. `privacyStatus`, `tags`). | + +--- + +## PATCH /templates/:id + +Update a template. Requires `EDITOR` role. + +**Request body:** Same fields as `POST /templates`, all optional. + +--- + +## DELETE /templates/:id + +Delete a template. Requires `ADMIN` role. + +--- + +## POST /templates/:id/preview + +Render a preview of the template's description for a given variable context. Does not require a video — useful for inspecting template output before applying. + +**Request body:** + +```json +{ + "variableValues": { + "custom_var": "value" + } +} +``` + +**Response:** + +```json +{ "rendered": "string" } +``` + +--- + +## Version history + +`TemplateVersion` rows are written to the database on every `PATCH /templates/:id` call, storing a snapshot of the template content before the update. When a template is deleted, its version rows are also deleted. + +There is **no `GET /templates/:id/versions` endpoint**. The version history is stored but unreachable via the API. Template version history is not exposed to the frontend. See the backlog. + +--- + +## POST /templates/:templateId/apply/:videoId + +Apply a template to a specific video. Requires `EDITOR` role. + +When applied: +- `template.defaultBlocks` is written to `VideoConfig.blockOrder` +- `template.defaultOverrides` is written to `VideoConfig.blockOverrides` +- If `applyVideoFields` is `true`, video metadata fields from `template.videoFields` are applied to the video row + +**Request body:** + +```json +{ + "applyVideoFields": true, + "applyDescriptionConfig": true +} +``` + +**Response:** Updated `VideoDetail` diff --git a/documentation/YouTube Studio Flow/03 - API Reference/05 - Collaborators API.md b/documentation/YouTube Studio Flow/03 - API Reference/05 - Collaborators API.md new file mode 100644 index 0000000..575c0be --- /dev/null +++ b/documentation/YouTube Studio Flow/03 - API Reference/05 - Collaborators API.md @@ -0,0 +1,93 @@ +# Collaborators API + +Base path: `/api/v1/collaborators` + +All endpoints require `Authorization: Bearer `. + +Related: [[02 - Description Engine]], [[03 - Blocks API]], [[04 - Database Schema]] + +--- + +## GET /collaborators + +List all collaborators for the current team. + +**Response:** `Collaborator[]` + +--- + +## GET /collaborators/:id + +Get a single collaborator by ID. + +**Response:** `Collaborator` + +--- + +## POST /collaborators + +Create a new collaborator. Requires `EDITOR` role. + +**Request body:** + +```json +{ + "name": "string", + "youtubeLink": "https://www.youtube.com/@handle", + "twitchLink": "https://www.twitch.tv/handle", + "instagramLink": "https://www.instagram.com/handle", + "tiktokLink": "https://www.tiktok.com/@handle", + "twitterLink": "https://x.com/handle", + "blueskyLink": "https://bsky.app/profile/handle", + "discordHandle": "username", + "aliases": ["string"], + "notes": "string" +} +``` + +All platform link fields are optional. `discordHandle` stores a username string rather than a URL. + +--- + +## PATCH /collaborators/:id + +Update a collaborator. Requires `EDITOR` role. + +**Request body:** Same fields as `POST /collaborators`, all optional. + +--- + +## DELETE /collaborators/:id + +Delete a collaborator. Requires `ADMIN` role. + +> Before deleting, check whether the collaborator is still referenced in any `Video.collaboratorIds` JSON array. Use the `array_contains` Prisma operator to query this field. + +--- + +## Collaborator tokens in descriptions + +When a `COLLABORATOR` or `VARIABLE` block references a collaborator, the following `{collab.*}` tokens are available: + +| Token | Resolves to | +|---|---| +| `{collab.name}` | Collaborator's display name | +| `{collab.youtube}` | Full YouTube URL (e.g. `https://www.youtube.com/@handle`) | +| `{collab.twitch}` | Twitch link | +| `{collab.instagram}` | Instagram link | +| `{collab.tiktok}` | TikTok link | +| `{collab.twitter}` | Twitter/X link | +| `{collab.bluesky}` | Bluesky link | +| `{collab.discord}` | Discord handle | +| `{collab.aliases}` | Aliases list | +| `{collab.notes}` | Notes field | + +> The legacy `{collab.handle}` token is no longer supported. Any block content still using it will not resolve — update those blocks manually to `{collab.youtube}`. + +All tokens are registered in `shared/system-variables/system-variables.registry.ts`. See [[02 - Description Engine]] for how they are resolved. + +--- + +## Collaborator tracking + +Collaborators are assigned to a video via `Video.collaboratorIds` — a JSON string array of collaborator IDs stored directly on the `Video` row. This is the single source for both rendering (`{collab.*}` token resolution) and filtering (the `collaboratorId` query param on `GET /videos`). There is no separate join table. diff --git a/documentation/YouTube Studio Flow/03 - API Reference/06 - Linting API.md b/documentation/YouTube Studio Flow/03 - API Reference/06 - Linting API.md new file mode 100644 index 0000000..ca43a99 --- /dev/null +++ b/documentation/YouTube Studio Flow/03 - API Reference/06 - Linting API.md @@ -0,0 +1,142 @@ +# Linting API + +Base path: `/api/v1/lint` + +All endpoints require `Authorization: Bearer `. + +Related: [[03 - Metadata Linting]], [[02 - Videos API]], [[09 - Teams API]] + +--- + +## POST /lint/videos/:id + +Run lint rules against a single video synchronously. Returns the issues found immediately. + +**Response:** `LintResult[]` + +`resolvedAt` is always `null` on freshly created results — it is only set when a result is explicitly resolved via `PATCH /lint/results/:id/resolve`. `GET /lint/results` also always returns `null` for `resolvedAt` because it queries only unresolved rows (`WHERE resolvedAt IS NULL`). + +--- + +## POST /lint/bulk + +Enqueue lint jobs for multiple videos. Processing is asynchronous via BullMQ. + +**Request body:** + +```json +{ "videoIds": ["cuid", "cuid"] } +``` + +**Response:** + +```json +{ "queued": number } +``` + +--- + +## POST /lint/channel/:channelId + +Enqueue lint jobs for all videos in a given channel. Processing is asynchronous. + +**Response:** + +```json +{ "queued": number } +``` + +--- + +## POST /lint/team + +Enqueue lint jobs for all videos in the current team. Processing is asynchronous. + +**Response:** + +```json +{ "queued": number } +``` + +--- + +## GET /lint/results + +Get all open (unresolved) lint results for the current team. + +**Query parameters:** + +| Param | Type | Description | +|---|---|---| +| `severity` | `INFO\|WARNING\|ERROR` | Filter by severity level. `INFO` is valid but no current rule produces it — reserved for future informational hints. | +| `ruleCode` | string | Filter by rule code (e.g. `TITLE_WEAK`) | +| `videoId` | string | Filter by video ID | + +**Response:** + +```json +[ + { + "id": "cuid", + "videoId": "cuid", + "ruleCode": "TITLE_WEAK", + "severity": "WARNING", + "targetField": "title", + "message": "Title appears too generic", + "fixSuggestion": "Use a more descriptive title", + "resolvedAt": null, + "createdAt": "ISO datetime", + "video": { + "id": "cuid", + "title": "Video Title" + } + } +] +``` + +--- + +## PATCH /lint/results/:id/resolve + +Mark a single lint result as resolved by setting `resolvedAt` to the current timestamp. + +**Response:** Updated `LintResult` + +--- + +## POST /lint/results/bulk-resolve + +Mark multiple lint results as resolved in one request. + +**Request body:** + +```json +{ "ids": ["cuid", "cuid"] } +``` + +**Response:** + +```json +{ "resolved": number } +``` + +--- + +## POST /lint/team/recompute-status + +Recompute `lintStatus` for all videos in the current team based on their actual open `LintResult` rows. Use this to heal stale or inconsistent status values. + +**Response:** + +```json +{ "checked": number, "updated": number } +``` + +--- + +## Notes + +- Individual lint rules can be disabled at the team level via `PATCH /teams/:teamId/settings` (`disabledLintRules` array). When a rule is disabled, its existing `LintResult` rows are deleted and affected video `lintStatus` values are recomputed automatically. +- `lintStatus` on `Video` reflects the most severe open result: `OK` → `WARNING` → `ERROR`. +- See [[03 - Metadata Linting]] for the full list of rule codes and their descriptions. +- See [[09 - Teams API]] for managing disabled lint rules at the team level. diff --git a/documentation/YouTube Studio Flow/03 - API Reference/07 - Playlists API.md b/documentation/YouTube Studio Flow/03 - API Reference/07 - Playlists API.md new file mode 100644 index 0000000..da4bc4b --- /dev/null +++ b/documentation/YouTube Studio Flow/03 - API Reference/07 - Playlists API.md @@ -0,0 +1,57 @@ +# Playlists API + +Base path: `/api/v1/playlists` + +All endpoints require `Authorization: Bearer `. + +Related: [[02 - Videos API]], [[04 - Database Schema]], [[10 - Quota API]] + +--- + +## GET /playlists/channel/:channelId + +List all playlists for a given channel. + +**Response:** `Playlist[]` + +--- + +## GET /playlists/video/:videoId + +List all playlists that a specific video belongs to. + +**Response:** `Playlist[]` + +--- + +## POST /playlists/channel/:channelId/sync + +Sync playlists from YouTube for the given channel, creating or updating local records to match the YouTube source. + +**Response:** + +```json +{ "synced": number } +``` + +**Cost:** Consumes YouTube API quota. See [[10 - Quota API]]. + +--- + +## POST /playlists/video/:videoId/:playlistId + +Add a video to a playlist. Creates a `VideoPlaylist` join record. + +**Response:** `VideoPlaylist` + +--- + +## DELETE /playlists/video/:videoId/:playlistId + +Remove a video from a playlist. Deletes the `VideoPlaylist` join record. + +**Response:** + +```json +{ "removed": true } +``` diff --git a/documentation/YouTube Studio Flow/03 - API Reference/08 - Saved Views API.md b/documentation/YouTube Studio Flow/03 - API Reference/08 - Saved Views API.md new file mode 100644 index 0000000..87f692a --- /dev/null +++ b/documentation/YouTube Studio Flow/03 - API Reference/08 - Saved Views API.md @@ -0,0 +1,91 @@ +# Saved Views API + +Base path: `/api/v1/saved-views` + +All endpoints require `Authorization: Bearer `. + +Related: [[02 - Videos API]], [[11 - Bulk Jobs API]] + +--- + +## GET /saved-views + +List all saved views for the current team. + +**Response:** `SavedView[]` + +--- + +## GET /saved-views/tabs + +List saved views that are pinned as tabs, ordered by `tabOrder`. + +**Response:** `SavedView[]` + +--- + +## POST /saved-views + +Create a new saved view. Requires `EDITOR` role. + +**Request body:** + +```json +{ + "name": "string", + "description": "string", + "isGlobal": false, + "queryJson": { + "lintStatus": "ERROR" + }, + "columnsJson": { + "visible": [], + "order": [] + }, + "sortJson": { + "field": "publishedAt", + "order": "desc" + }, + "pinnedAsTab": false, + "tabOrder": null +} +``` + +**Field notes:** + +| Field | Notes | +|---|---| +| `isGlobal` | If `true`, the view is visible to all team members. If `false`, it is personal. | +| `queryJson` | Filter parameters — same shape as the query params accepted by `GET /videos`. | +| `columnsJson` | Column visibility and order configuration for the video table. | +| `sortJson` | Default sort field and direction for the view. | +| `pinnedAsTab` | Whether the view appears as a tab in the video list UI. | +| `tabOrder` | Integer position in the tab bar. `null` if not pinned. | + +--- + +## PATCH /saved-views/:id + +Update a saved view. Requires `EDITOR` role. + +**Request body:** Same fields as `POST /saved-views`, all optional. + +--- + +## DELETE /saved-views/:id + +Delete a saved view. Requires `ADMIN` role. + +--- + +## POST /saved-views/:id/execute + +Execute the saved view's stored query and return the matching video IDs. + +**Response:** + +```json +{ "videoIds": ["cuid", "cuid"] } +``` + +> The returned `videoIds` can be passed directly to bulk job endpoints such as `POST /bulk-jobs/push-pending` or `POST /bulk-jobs/preview`. See [[11 - Bulk Jobs API]]. diff --git a/documentation/YouTube Studio Flow/03 - API Reference/09 - Teams API.md b/documentation/YouTube Studio Flow/03 - API Reference/09 - Teams API.md new file mode 100644 index 0000000..2c8339c --- /dev/null +++ b/documentation/YouTube Studio Flow/03 - API Reference/09 - Teams API.md @@ -0,0 +1,161 @@ +# Teams API + +Base path: `/api/v1/teams` + +All endpoints require `Authorization: Bearer `. + +Related: [[01 - Auth API]], [[06 - Linting API]], [[04 - Database Schema]] + +--- + +## GET /teams/mine + +List all teams the current user belongs to. + +**Response:** `Team[]` + +--- + +## GET /teams/:teamId + +Get team details including members and connected channels. + +**Response:** `TeamDetail` + +--- + +## GET /teams/:teamId/channels + +List connected YouTube channels for a team. + +**Response:** `Channel[]` + +--- + +## POST /teams/:teamId/members + +Invite a user to the team by email address. Requires `ADMIN` role. + +**Request body:** + +```json +{ + "email": "user@example.com", + "role": "EDITOR" +} +``` + +**Possible `role` values:** `ADMIN`, `EDITOR`, `REVIEWER`, `READONLY` + +> `OWNER` is assigned at team creation and cannot be set via this endpoint. + +**Response:** `TeamMember` + +--- + +## PATCH /teams/:teamId/members/:userId + +Change a team member's role. Requires `ADMIN` role. + +**Request body:** + +```json +{ "role": "EDITOR" } +``` + +--- + +## DELETE /teams/:teamId/members/:userId + +Remove a member from the team. Requires `ADMIN` role. + +--- + +## GET /teams/:teamId/settings + +Get team-level render and feature settings. + +**Response:** + +```json +{ + "dateFormat": "DD.MM.YYYY", + "timezone": "Europe/Berlin", + "publishingSchedule": [], + "showCanvaLink": false, + "disabledLintRules": [], + "showDeletedVideos": false, + "conflictDetectionEnabled": false, + "conflictDetectionBatchSize": 50, + "conflictDetectionMinAgeDays": 7 +} +``` + +**Field notes:** + +| Field | Notes | +|---|---| +| `dateFormat` | Default date format applied by the render engine to `{video.scheduledAt}` and `{video.recordingDate}` tokens when no inline format is specified (e.g. `{video.scheduledAt\|DD.MM.YYYY}` overrides this). | +| `timezone` | Team's local timezone, used for scheduling display. | +| `publishingSchedule` | Array of preferred publishing time slots used by the next-slot calculator. | +| `showCanvaLink` | Whether to show Canva thumbnail link in the video editor UI. | +| `disabledLintRules` | Array of rule codes (e.g. `TITLE_WEAK`) that are suppressed for this team. | +| `showDeletedVideos` | Whether soft-deleted videos appear in the video list. | +| `conflictDetectionEnabled` | Opt-in for the scheduled remote-conflict sweep. Only takes effect when the operator has also set `CONFLICT_DETECTION_ENABLED=true` on the worker. | +| `conflictDetectionBatchSize` | Max videos checked per sweep for this team (1–500). | +| `conflictDetectionMinAgeDays` | Skip videos whose `lastSyncedAt` is within the last N days (≥0). | + +--- + +## PATCH /teams/:teamId/settings + +Update team settings. Requires `ADMIN` role. + +**Request body** (all fields optional): + +```json +{ + "dateFormat": "DD.MM.YYYY", + "timezone": "Europe/Berlin", + "publishingSchedule": [], + "showCanvaLink": true, + "disabledLintRules": ["TITLE_WEAK"], + "showDeletedVideos": false, + "conflictDetectionEnabled": true, + "conflictDetectionBatchSize": 100, + "conflictDetectionMinAgeDays": 14 +} +``` + +**Validation:** + +- `conflictDetectionBatchSize` must be `1 ≤ n ≤ 500` +- `conflictDetectionMinAgeDays` must be `≥ 0` + +Invalid values return `400 Bad Request`. + +**Side effects:** + +- If `disabledLintRules` changes, existing `LintResult` rows for any newly disabled rules are deleted and `Video.lintStatus` is recomputed for all affected videos automatically. + +See [[06 - Linting API]] for more on lint result management. See [[05 - Queue System]] for how the conflict-detection settings drive the scheduled sweep. + +--- + +## GET /teams/:teamId/next-publish-slot + +Find the next available publishing slot based on the team's configured publishing schedule. + +**Query parameters:** + +| Param | Type | Required | Description | +|---|---|---|---| +| `channelId` | string | Yes | The channel to check slots for | + +**Response:** + +```json +{ "slot": "ISO datetime | null" } +``` + +`null` is returned if no publishing schedule has been configured for the team. diff --git a/documentation/YouTube Studio Flow/03 - API Reference/10 - Quota API.md b/documentation/YouTube Studio Flow/03 - API Reference/10 - Quota API.md new file mode 100644 index 0000000..fc4e08d --- /dev/null +++ b/documentation/YouTube Studio Flow/03 - API Reference/10 - Quota API.md @@ -0,0 +1,78 @@ +# Quota API + +Base path: `/api/v1/quota` + +All endpoints require `Authorization: Bearer `. + +Related: [[02 - Videos API]], [[01 - System Overview]] + +--- + +## GET /quota/history + +Get YouTube API quota usage history for the current team. + +**Query parameters:** + +| Param | Type | Description | +|---|---|---| +| `from` | ISO date | Start of the date range | +| `to` | ISO date | End of the date range | +| `channelId` | string | Filter by channel | + +**Response:** + +```json +[ + { + "id": "cuid", + "datePt": "ISO datetime", + "units": 50, + "operation": "videos.update", + "actionId": "uuid", + "actionType": "video_sync", + "channelId": "cuid", + "videoId": "cuid", + "video": { + "id": "cuid", + "title": "Video Title", + "youtubeVideoId": "abc123" + } + } +] +``` + +**Field notes:** + +| Field | Notes | +|---|---| +| `datePt` | Timestamp of the quota spend, in Pacific Time (quota resets at midnight PT). | +| `units` | Number of quota units consumed by the operation. | +| `operation` | YouTube API method called (e.g. `videos.update`). | +| `actionType` | Internal action that triggered the spend (e.g. `video_sync`). | + +--- + +## GET /quota/today + +Get the total YouTube API quota consumed today (Pacific Time), plus the daily limit and remaining units. + +**Response:** + +```json +{ + "used": 450, + "limit": 10000, + "remaining": 9550 +} +``` + +--- + +## Notes + +- The YouTube API quota limit is **10,000 units per day**, resetting at midnight Pacific Time. +- A `videos.update` call (triggered by a sync job) costs **50 units**. +- `QuotaService.canSpend()` and `.spend()` are called before every YouTube write. If the remaining quota is insufficient, the operation is rejected. +- Quota is tracked per team and per channel. +- See [[02 - Videos API]] — `POST /videos/:id/sync` for the sync flow that consumes quota. diff --git a/documentation/YouTube Studio Flow/03 - API Reference/11 - Bulk Jobs API.md b/documentation/YouTube Studio Flow/03 - API Reference/11 - Bulk Jobs API.md new file mode 100644 index 0000000..1d43a65 --- /dev/null +++ b/documentation/YouTube Studio Flow/03 - API Reference/11 - Bulk Jobs API.md @@ -0,0 +1,148 @@ +# Bulk Jobs API + +Base path: `/api/v1/bulk-jobs` + +All endpoints require `Authorization: Bearer `. + +Related: [[02 - Videos API]], [[08 - Saved Views API]], [[10 - Quota API]] + +--- + +## GET /bulk-jobs + +List bulk jobs for the current team. + +**Query parameters:** + +| Param | Type | Description | +|---|---|---| +| `status` | string | Optional filter by job status | + +**Response:** `BulkJob[]` + +--- + +## GET /bulk-jobs/:id + +Get a single bulk job with all of its individual item records. + +**Response:** `BulkJob` with `items: BulkJobItem[]` + +--- + +## POST /bulk-jobs/:id/rollback + +Roll back a completed bulk job, reverting the changes made to each item. Requires `EDITOR` role. + +**Response:** Updated `BulkJob` + +**What rollback does:** + +For each `BulkJobItem` with `status: "done"`, the rollback service writes `item.beforeSnapshot` back to the `Video` row. `beforeSnapshot` contains the `youtubeSnapshot` fields captured before the push: `title`, `tags`, `categoryId`, `privacyStatus`, `defaultLanguage`, `defaultAudioLanguage`, `selfDeclaredMadeForKids`, `embeddable`, `license`, `recordingDate`. + +**For `SYNC_PUSH` jobs specifically:** + +Rollback is a **local-only operation**. YouTube is not contacted and the data already pushed to YouTube is not reversed. After rollback: + +- The `Video` row's fields are restored to their pre-push values. +- `lastSyncedHash` is **not** restored — it retains the hash computed at push time. +- Because the local fields no longer match `lastSyncedHash`, the video will show as "push pending" again. This is the expected outcome: the local DB now diverges from YouTube, and a new push is required to re-align them. + +This means rollback on a `SYNC_PUSH` job does not undo anything on YouTube — it only rewinds the local record and marks the video as needing another sync. + +--- + +## GET /bulk-jobs/push-pending/preview + +Preview all push-pending videos with field-level diffs showing what will change when pushed to YouTube. + +**Query parameters:** + +| Param | Type | Description | +|---|---|---| +| `sort` | string | Field to sort by | +| `order` | `asc\|desc` | Sort direction | + +**Response:** Array of per-video diff previews + +--- + +## POST /bulk-jobs/push-pending + +Create a bulk sync job for a selected set of push-pending videos. Enqueues YouTube sync jobs for each video. Requires `EDITOR` role. + +**Request body:** + +```json +{ "videoIds": ["cuid", "cuid"] } +``` + +> Tip: use `POST /saved-views/:id/execute` to get a `videoIds` list from a saved view. See [[08 - Saved Views API]]. + +**Response:** Created `BulkJob` + +--- + +## Bulk Change (Header modal) + +The following endpoints power the bulk metadata change modal in the application header. + +### POST /bulk-jobs/preview + +Preview the effect of a bulk metadata change before applying it. + +**Request body:** + +```json +{ + "type": "SET_PRIVACY|SET_TEMPLATE|ADD_TAGS|REMOVE_TAGS|SEARCH_REPLACE_TITLE", + "payload": {}, + "savedViewId": "cuid" +} +``` + +**`type` values:** + +| Type | Description | +|---|---| +| `SET_PRIVACY` | Change privacy status for matched videos | +| `SET_TEMPLATE` | Apply a template to matched videos | +| `ADD_TAGS` | Add tags to matched videos | +| `REMOVE_TAGS` | Remove tags from matched videos | +| `SEARCH_REPLACE_TITLE` | Find and replace in video titles | + +`savedViewId` is optional. When provided, the operation is scoped to videos matching that saved view. + +**Response:** + +```json +{ + "count": 5, + "type": "SET_PRIVACY", + "previews": [ + { + "videoId": "cuid", + "before": { "privacyStatus": "PRIVATE" }, + "after": { "privacyStatus": "PUBLIC" } + } + ] +} +``` + +--- + +### POST /bulk-jobs/apply + +Apply a previewed bulk metadata change. Creates a `BulkJob` record and processes each matched video. Requires `EDITOR` role. + +**Request body:** Same shape as `POST /bulk-jobs/preview`. + +**Response:** Created `BulkJob` + +--- + +## Notes + +- Bulk sync jobs consume YouTube API quota (50 units per video pushed). Check remaining quota via `GET /quota/today` before triggering large bulk pushes. See [[10 - Quota API]]. +- `BulkJob` items record `before` and `after` snapshots for each video, enabling rollback. +- Rollback is only available for completed jobs and reverses the stored `before` snapshot. diff --git a/documentation/YouTube Studio Flow/03 - API Reference/12 - Calendar API.md b/documentation/YouTube Studio Flow/03 - API Reference/12 - Calendar API.md new file mode 100644 index 0000000..0cdda37 --- /dev/null +++ b/documentation/YouTube Studio Flow/03 - API Reference/12 - Calendar API.md @@ -0,0 +1,61 @@ +# Calendar API + +Base path: `/api/v1/calendar` + +All endpoints require `Authorization: Bearer `. + +Related: [[02 - Videos API]], [[04 - Database Schema]] + +--- + +## GET /calendar + +Get calendar entries for the current team. Returns scheduled and published videos within the requested date range. + +**Query parameters:** + +| Param | Values | Description | +|---|---|---| +| `view` | `month\|week\|agenda` | Calendar view mode | +| `date` | `YYYY-MM` | Reference month or date | + +**Date ranges by view mode:** + +| View | Range returned | +|---|---| +| `month` | Full calendar month for the given `YYYY-MM` | +| `week` | The week containing day 1 of the given month. Because `date` is `YYYY-MM` with no day component, the week always anchors to the 1st — arbitrary week navigation is not supported. **Not used by the frontend.** | +| `agenda` | Next 30 days from the given date | + +**Response:** + +```json +[ + { + "videoId": "cuid", + "title": "Video Title", + "date": "ISO datetime", + "templateName": "Template Name", + "collaborators": [ + { + "id": "cuid", + "name": "Collaborator Name", + "youtubeLink": "https://www.youtube.com/@handle" + } + ], + "lintStatus": "OK", + "channelId": "cuid", + "privacyStatus": "PRIVATE" + } +] +``` + +**Response field notes:** + +| Field | Notes | +|---|---| +| `date` | The video's `scheduledAt` or `publishedAt` datetime used to place it on the calendar. | +| `templateName` | Name of the template applied to this video, if any. | +| `collaborators` | Collaborators assigned via `Video.collaboratorIds`, resolved to name and YouTube link. | +| `lintStatus` | Current lint status: `OK`, `WARNING`, or `ERROR`. | +| `privacyStatus` | `PUBLIC`, `PRIVATE`, or `UNLISTED`. | diff --git a/documentation/YouTube Studio Flow/03 - API Reference/13 - Import Export API.md b/documentation/YouTube Studio Flow/03 - API Reference/13 - Import Export API.md new file mode 100644 index 0000000..52e7c33 --- /dev/null +++ b/documentation/YouTube Studio Flow/03 - API Reference/13 - Import Export API.md @@ -0,0 +1,160 @@ +# Import / Export API + +All endpoints require `Authorization: Bearer `. + +Related: [[02 - Videos API]], [[03 - Blocks API]], [[04 - Templates API]], [[05 - Collaborators API]] + +--- + +## Import + +Base path: `/api/v1/imports` + +All import endpoints require `EDITOR` role. + +--- + +### POST /imports/csv/preview + +Upload and validate a CSV file before committing. Returns a validation report and an `importJobId` to use in the commit step. + +**Content-Type:** `multipart/form-data` + +**Form fields:** + +| Field | Type | Description | +|---|---|---| +| `file` | file | CSV file to import | +| `mapping` | string (JSON) | Maps CSV column names to video fields | + +**Response:** + +```json +{ + "importJobId": "cuid", + "validRows": 50, + "errors": [ + { "row": 3, "field": "privacyStatus", "message": "Invalid enum value" } + ] +} +``` + +| Field | Description | +|---|---| +| `importJobId` | Token to pass to the commit step. | +| `validRows` | Count of rows that passed validation. | +| `errors` | Array of per-row validation failures. Each entry has `row` (1-based), `field`, and `message`. Empty array means all rows are valid. | + +The full validation detail is also stored in `ImportJob.validationReport` (shape: `{ validCount, errorCount, errors }`). There is no `warnings` array. + +--- + +### POST /imports/csv/commit + +Enqueues a BullMQ job to process a previously validated CSV import. Returns immediately — the actual row processing happens asynchronously in the worker. + +> **Known limitation:** The import processor currently only marks the `ImportJob` as `committed` and writes an audit log entry. It does not create or update any `Video` rows. The validated rows from the preview step are not persisted and are not available to the processor. CSV import is effectively incomplete — see the backlog. + +**Request body:** + +```json +{ "importJobId": "cuid" } +``` + +**Response:** + +```json +{ "queued": true, "importJobId": "cuid" } +``` + +--- + +### POST /imports/json/preview + +Validate a JSON workspace payload (blocks, templates, variables, collaborators) before committing. Returns an `importJobId` to use in the commit step. + +> **Note:** The payload is **not stored** during preview. The `importJobId` is a commit-guard token only — it confirms a preview was completed and prevents double-commit. The full payload must be resent on commit. + +**Request body:** Workspace JSON payload + +**Response:** + +```json +{ + "importJobId": "cuid", + "valid": true +} +``` + +--- + +### POST /imports/json/commit + +Apply a previously validated JSON workspace import. The full workspace payload must be included again — it was not stored during the preview step. + +Commit is **synchronous** (no queue). Upserts `collaborators`, `blocks`, and `templates` by `id`. `videos`, `videoConfigs`, and `savedViews` in the payload are silently ignored. + +**Request body:** + +```json +{ + "importJobId": "cuid", + "payload": { } +} +``` + +**Response:** + +```json +{ "committed": true } +``` + +--- + +## Export + +Base path: `/api/v1/exports` + +--- + +### POST /exports/csv + +Export video metadata as a CSV file download. There is no `scope` parameter — filtering is controlled by `videoIds` or `savedViewId`. + +**Request body:** + +```json +{ + "videoIds": ["cuid1", "cuid2"], + "savedViewId": "cuid" +} +``` + +| Field | Notes | +|---|---| +| `videoIds` | Export only these specific videos. If omitted, falls back to `savedViewId`. | +| `savedViewId` | If `videoIds` is not provided, loads the saved view's stored `queryJson` and uses it to select which videos to export. | + +If neither field is provided, all videos in the team are exported. + +**CSV columns:** `youtube_video_id`, `title`, `tags`, `category_id`, `privacy_status`, `published_at`, `scheduled_at`, `template`, `lint_status` + +**Response:** `text/csv` file download (`studioflow-export-.csv`) + +--- + +### POST /exports/json + +Export the full workspace as a JSON object. No request body, no filtering — exports everything. + +**Response:** JSON object containing: `version`, `exportedAt`, `videos`, `videoConfigs`, `blocks`, `templates`, `collaborators`, `savedViews` + +> **Note:** `ExportJob` rows are never written. There is no export history or re-download — each call generates the export fresh. See the backlog. + +--- + +## Notes + +- The preview-then-commit pattern for imports allows validation errors to be surfaced and reviewed before any data is written. +- CSV column mapping must be provided as a JSON string in the `mapping` form field, mapping CSV header names to the corresponding video model fields accepted by `PATCH /videos/:id`. +- JSON workspace exports can be re-imported via `POST /imports/json/preview` and `POST /imports/json/commit`, enabling workspace migration between teams or environments. diff --git a/documentation/YouTube Studio Flow/03 - API Reference/14 - Team Variables API.md b/documentation/YouTube Studio Flow/03 - API Reference/14 - Team Variables API.md new file mode 100644 index 0000000..9324e12 --- /dev/null +++ b/documentation/YouTube Studio Flow/03 - API Reference/14 - Team Variables API.md @@ -0,0 +1,133 @@ +# Team Variables API + +Base path: `/api/v1/team-variables` + +All endpoints require `Authorization: Bearer `. + +Related: [[07 - Render Engine]], [[04 - Database Schema]], [[02 - Description Engine]] + +--- + +## GET /team-variables + +List all team variables, ordered alphabetically by name. + +**Minimum role:** Any authenticated team member + +**Response:** `TeamVariable[]` + +```json +[ + { + "id": "cuid", + "teamId": "cuid", + "name": "sponsor_link", + "value": "https://example.com/sponsor", + "createdAt": "ISO datetime", + "updatedAt": "ISO datetime" + } +] +``` + +--- + +## POST /team-variables + +Create a new team variable. + +**Minimum role:** `EDITOR` + +**Request body:** + +```json +{ + "name": "sponsor_link", + "value": "https://example.com/sponsor" +} +``` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `name` | string | Yes | Used as the token key: `{name}` in description blocks | +| `value` | string | Yes | The string substituted at render time | + +**Response:** The created `TeamVariable` object. + +**Side effects:** +- All non-deleted videos in the team are enqueued for background re-render (queue: `render`, job ID `render-{videoId}` — deduplicated). +- Mutation is audit-logged under entity type `TeamVariable`. + +--- + +## PATCH /team-variables/:id + +Update an existing team variable. + +**Minimum role:** `EDITOR` + +**Request body** (all fields optional): + +```json +{ + "name": "sponsor_link", + "value": "https://new-sponsor.com" +} +``` + +**Response:** The updated `TeamVariable` object. + +**Side effects:** Same as POST — full team re-render is enqueued, mutation is audit-logged. + +--- + +## GET /team-variables/:id/usage + +Return the description blocks that reference this variable by name token. + +**Minimum role:** Any authenticated team member + +**Response:** + +```json +{ + "blocks": [ + { "id": "cuid", "name": "Sponsor Block" } + ] +} +``` + +Blocks are matched by scanning their `content` field for the literal string `{name}` where `name` is the variable's current name. Only blocks belonging to the same team are returned. + +> **Note:** This endpoint uses the variable's current name. If you rename a variable, existing blocks that still use the old token name will not appear here — and will silently resolve to an empty string at render time until updated. + +--- + +## DELETE /team-variables/:id + +Delete a team variable. + +**Minimum role:** `EDITOR` + +> **Note:** Every other destructive delete in the API (blocks, templates, collaborators, saved views) requires `ADMIN`. Team variable delete only requires `EDITOR`, despite triggering a full team re-render and silently breaking any block that references the deleted token. This appears to be an oversight — see the backlog. + +**Response:** + +```json +{ "deleted": true } +``` + +**Side effects:** Same as POST — full team re-render is enqueued, mutation is audit-logged. + +> **Warning:** Deleting a variable does not remove references to its token from description blocks. Blocks that used `{name}` will continue to contain the token string; it will resolve to an empty string at render time. + +--- + +## How variables are resolved at render time + +Team variables form the baseline layer of variable resolution. When a video is rendered: + +1. All team variables are loaded as a flat `Record` map (name → value). +2. Video-level variable overrides from `VideoConfig.variableValues` are merged on top — video-level values win. +3. The combined map is used to substitute `{variable_name}` tokens in `VARIABLE`-type description blocks. + +System variable tokens (`{video.*}`, `{collab.*}`) are skipped during this pass and resolved by dedicated resolvers. See [[07 - Render Engine]] for the full resolution order. diff --git a/documentation/YouTube Studio Flow/03 - API Reference/15 - Campaigns API.md b/documentation/YouTube Studio Flow/03 - API Reference/15 - Campaigns API.md new file mode 100644 index 0000000..7043c91 --- /dev/null +++ b/documentation/YouTube Studio Flow/03 - API Reference/15 - Campaigns API.md @@ -0,0 +1,55 @@ +# Campaigns API + +Base path: `/api/v1/campaigns` + +All endpoints require `Authorization: Bearer `. + +Related: [[08 - Campaigns]], [[07 - Render Engine]], [[04 - Database Schema]] + +--- + +## GET /campaigns + +List all campaigns for the current team, ordered by `startAt` descending (most recent first). + +**Minimum role:** Any authenticated team member + +**Response:** `CampaignSummary[]` + +```json +[ + { + "id": "cuid", + "name": "Summer Sale 2026", + "startAt": "2026-06-01T00:00:00.000Z", + "endAt": "2026-08-31T23:59:59.000Z", + "status": "active" + } +] +``` + +| Field | Type | Notes | +|---|---|---| +| `id` | string | cuid | +| `name` | string | Display name of the campaign | +| `startAt` | ISO datetime | Campaign window start (inclusive) | +| `endAt` | ISO datetime \| null | Campaign window end (inclusive). `null` means open-ended | +| `status` | string | Free-form status string, default `"active"`. **Must be exactly `"active"` (lowercase) for the campaign to be treated as active** — see warning below. | + +> **Warning:** `Campaign.status` is a free-form `String` column with no enum constraint. The `DESC_OUTDATED_SPONSOR_COPY` lint rule checks `status !== 'active'` (exact lowercase match). Any other value — `"ACTIVE"`, `"paused"`, `"inactive"`, `"disabled"`, or any typo — is treated as inactive: the campaign's blocks are excluded from rendering and all videos referencing them receive a lint ERROR. There is no input validation at the API or DB level. See the backlog. + +> **Note:** The full `notes` field and the linked `blocks` relation are not included in this response. Campaigns are read here for display and for populating dropdowns when assigning blocks to a campaign. Full campaign management (create, update, delete, assign blocks) is not exposed via the REST API — it is managed directly in the database or through future admin tooling. + +--- + +## How campaigns affect rendering + +Campaign blocks are `CAMPAIGN`-type `DescriptionBlock` rows linked to a campaign via `campaignId`. At render time, the engine checks whether `startAt ≤ now ≤ endAt` for each linked campaign. If the window is active, all of that campaign's blocks are automatically appended to the rendered description — regardless of whether they appear in the video's `blockOrder`. If the window is inactive, they are silently omitted. + +This is the only block type that bypasses `blockOrder`. See [[07 - Render Engine]] for the full rendering pipeline. + +--- + +## Current limitations + +Campaign CRUD (create, update, delete) and block assignment are not yet exposed through the API. The `GET /campaigns` endpoint exists to allow the frontend to display campaign names and populate selectors. Full campaign management requires direct database access or a future admin interface. See **Campaign CRUD API** in [[01 - Technical Debt and Future Work]]. diff --git a/documentation/YouTube Studio Flow/03 - API Reference/16 - YouTube Sync API.md b/documentation/YouTube Studio Flow/03 - API Reference/16 - YouTube Sync API.md new file mode 100644 index 0000000..2c6ded5 --- /dev/null +++ b/documentation/YouTube Studio Flow/03 - API Reference/16 - YouTube Sync API.md @@ -0,0 +1,200 @@ +# YouTube Sync API + +Base path: `/api/v1/youtube-sync` + +All endpoints require `Authorization: Bearer `. + +Related: [[13 - Team Settings]], [[01 - Video Management]], [[05 - Queue System]], [[06 - Authentication]] + +--- + +## How channel connection works + +There is **no separate channel-connect endpoint**. A YouTube channel is connected automatically during the user's **first Google OAuth login**: + +1. User hits `GET /auth/google` — redirected to Google consent screen. +2. Google returns an OAuth callback with `accessToken` and `refreshToken`. +3. `AuthService.upsertGoogleUser` detects that the user has no team memberships. +4. It calls the YouTube API (`channels.list?mine=true`) using the login OAuth tokens to fetch the user's channel ID, name, and uploads playlist ID. +5. A `Team` record is created (user as `OWNER`), and a `Channel` record is created and linked — with the YouTube access and refresh tokens stored AES-256 encrypted (`TOKEN_ENCRYPTION_KEY`). + +On **subsequent logins**, if the user already has a team, the access token is refreshed on all channels they own (`connectedBy = user.id`). A new refresh token is only stored if Google returns one (which only happens on first auth or after explicit re-consent). + +**Listing connected channels:** `GET /teams/:teamId/channels` — see [[09 - Teams API]]. + +**Channel disconnect:** Not currently implemented. Removing a channel requires direct database access. + +> **Important:** The channel OAuth tokens are the **same tokens** obtained during user login — not a separate OAuth flow. This means one Google account = one YouTube channel connection at team-creation time. Adding a second channel to an existing team is not currently supported through the API. + +--- + +## POST /youtube-sync/channel-import + +Pull all videos from a connected YouTube channel into the local database. + +**Minimum role:** `EDITOR` + +**Request body:** + +```json +{ "channelId": "cuid" } +``` + +**Response:** Import summary + +```json +{ + "total": 57, + "created": 12, + "updated": 45, + "deleted": 0, + "deletedTitles": [] +} +``` + +| Field | Description | +|---|---| +| `total` | Total number of video IDs processed from the YouTube playlist | +| `created` | New `Video` rows created | +| `updated` | Existing `Video` rows updated | +| `deleted` | Videos soft- or hard-deleted during this import (see `channel-purge-deleted` for the purge lifecycle) | +| `deletedTitles` | Titles of soft-deleted videos | + +There is no `skipped` field. A video item returned by the YouTube API with no `id` field is silently dropped and counted in neither `created` nor `updated` — this is an edge case in malformed API responses and does not appear in the summary. + +**Behavior:** +- Fetches the channel's uploads playlist from YouTube page by page. +- For each video: creates a new `Video` row (with `VideoConfig`) or updates the existing one. +- Sets `lastSyncedHash` on every upserted video so the YouTube state is the baseline (no false "push pending" after import). +- Also syncs playlists and their video memberships — subject to the `itemCount` optimization described below. +- Does **not** delete local videos that are missing from YouTube — use `channel-purge-deleted` for that. + +**Playlist `itemCount` optimization:** During playlist membership sync, each playlist's current `itemCount` (from the YouTube API) is compared against the cached count stored in the local `Playlist` row from the previous import. If the counts match, that playlist's full video-membership sync is **skipped entirely** — no `playlistItems.list` API call is made for it. This saves quota on large channels where most playlists are unchanged. The cached `itemCount` is updated on every import regardless of whether the sync was skipped. + +**Known limitation:** If videos are moved between playlists such that one playlist gains a video and another loses one in the same import run, both playlists may have unchanged `itemCount` values and their membership sync will be skipped, leaving the local `VideoPlaylist` join table stale. Use `channel-full-refresh` to force-sync all playlists. + +> Costs YouTube API quota. Each page of 50 videos costs 1 `playlistItems.list` unit; individual video detail fetches cost 1 unit each. + +--- + +## POST /youtube-sync/channel-full-refresh + +Force-reimport all videos and playlists for a channel, bypassing the `itemCount` optimization — all playlists have their full video membership re-synced regardless of cached counts. Use this when a normal import appears to have missed playlist membership changes. + +**Minimum role:** `ADMIN` + +**Request body:** + +```json +{ "channelId": "cuid" } +``` + +**Response:** Same import summary shape as `channel-import`. + +--- + +## POST /youtube-sync/import-video-ids + +Immediately import specific YouTube video IDs into the database, bypassing the normal playlist-based discovery. + +**Minimum role:** `ADMIN` + +**Request body:** + +```json +{ + "channelId": "cuid", + "videoIds": ["dQw4w9WgXcQ", "abc123"] +} +``` + +**Response:** + +```json +{ + "total": 2, + "created": 1, + "updated": 1, + "notFound": [] +} +``` + +`notFound` lists any requested YouTube video IDs that the YouTube API did not return (private, deleted, or wrong ID). Unlike `channel-import`, this endpoint has no `deleted`/`deletedTitles` fields — it does not run the purge lifecycle. + +Use this for videos that are unlisted or otherwise not reachable via the uploads playlist. + +--- + +## POST /youtube-sync/channels/:channelId/supplemental-ids + +Add YouTube video IDs to the channel's supplemental list. Videos on this list are always imported during future `channel-import` runs, regardless of playlist membership. + +**Minimum role:** `ADMIN` + +**Request body:** + +```json +{ "videoIds": ["dQw4w9WgXcQ"] } +``` + +**Response:** Updated supplemental ID list. + +--- + +## POST /youtube-sync/channel-purge-deleted + +Check each local video against YouTube and soft-delete any that no longer exist. Also restores soft-deleted videos that have reappeared. + +**Minimum role:** `ADMIN` + +**Request body:** + +```json +{ "channelId": "cuid" } +``` + +**Response:** + +```json +{ + "checked": 200, + "softDeleted": 3, + "restored": 0, + "hardDeleted": 0, + "softDeletedTitles": ["Old Video Title", ...] +} +``` + +Each video follows a three-state lifecycle during purge: + +1. **First miss → soft delete.** If a video is not found on YouTube and `youtubeDeletedAt` is null, `youtubeDeletedAt` is set to now. The video is hidden from normal list queries but remains in the database. +2. **Grace period → no action.** If `youtubeDeletedAt` is already set but fewer than 30 days have passed, the video is left untouched. +3. **30-day expiry → hard delete.** If `youtubeDeletedAt` is set and is more than 30 days old, the video row (and its related records) is permanently deleted from the database. This is what `hardDeleted` counts. +4. **Reappearance → restore.** If a previously soft-deleted video reappears on YouTube (e.g. it was temporarily private), `youtubeDeletedAt` is cleared and the video is visible again. + +--- + +## GET /youtube-sync/queue-status + +Inspect the current state of the YouTube sync (`youtube-sync`) BullMQ queue. + +**Minimum role:** Any authenticated team member + +**Response:** + +```json +{ + "active": [ + { "jobId": "string", "videoId": "cuid", "videoTitle": "string", "addedAt": "ISO datetime" } + ], + "waiting": [...], + "recentFailed": [ + { "jobId": "string", "videoId": "cuid", "videoTitle": "string", "addedAt": "ISO datetime", "failedReason": "string", "failedAt": "ISO datetime" } + ], + "recentCompleted": [ + { "jobId": "string", "videoId": "cuid", "videoTitle": "string", "addedAt": "ISO datetime", "completedAt": "ISO datetime" } + ] +} +``` + +Returns up to 10 recent failed jobs and 5 recent completed jobs. diff --git a/documentation/YouTube Studio Flow/03 - API Reference/17 - Video Configs API.md b/documentation/YouTube Studio Flow/03 - API Reference/17 - Video Configs API.md new file mode 100644 index 0000000..40989d1 --- /dev/null +++ b/documentation/YouTube Studio Flow/03 - API Reference/17 - Video Configs API.md @@ -0,0 +1,102 @@ +# Video Configs API + +Base path: `/api/v1/video-configs` + +All endpoints require `Authorization: Bearer `. + +Related: [[02 - Videos API]], [[07 - Render Engine]], [[04 - Templates API]] + +--- + +## GET /video-configs/:videoId + +Returns the current `VideoConfig` for a video. + +**Response:** + +```json +{ + "id": "cuid", + "videoId": "cuid", + "templateId": "cuid or null", + "blockOrder": ["blockId1", "freetext:abc123"], + "blockOverrides": { + "blockId1": { "content": "override text", "active": true, "compact": false } + }, + "variableValues": { + "sponsor_name": "Acme Corp" + }, + "version": 3, + "renderHash": "sha256hex or null", + "renderedAt": "ISO datetime or null", + "createdAt": "ISO datetime", + "updatedAt": "ISO datetime" +} +``` + +Returns `null` if the video has no config yet. + +--- + +## PUT /video-configs/:videoId + +Save (create or update) the description config for a video. Requires `EDITOR` role. + +This is the endpoint the video editor calls when the user saves their block configuration. + +**Request body:** + +```json +{ + "blockOrder": ["blockId1", "freetext:abc123"], + "blockOverrides": { + "blockId1": { "content": "override text", "active": true, "compact": false }, + "freetext:abc123": { "content": "free text content", "compact": false } + }, + "variableValues": { + "sponsor_name": "Acme Corp" + }, + "templateId": "cuid", + "collaboratorIds": ["collabId1"], + "autoRender": true +} +``` + +| Field | Required | Notes | +|---|---|---| +| `blockOrder` | Yes | Ordered array of block IDs. May include `freetext:` entries — these have no DB block; content comes from `blockOverrides`. | +| `blockOverrides` | Yes | Per-block overrides for content, active state, and compact flag. | +| `variableValues` | Yes | Video-level custom variable values. Override team variables on name collision. | +| `templateId` | No | Associates the config with a template. Does not re-apply template defaults — it is a reference only. | +| `collaboratorIds` | No | Updates `Video.collaboratorIds` on the video row. Controls which collaborators are resolved during description rendering. | +| `autoRender` | No | If `true`, enqueues a background render job after saving. Updates `Video.renderedDescription` asynchronously. | + +**Response:** The saved `VideoConfig` row (same shape as GET response above). + +**Notes:** +- `version` is auto-incremented on every save. +- `collaboratorIds` in the request body updates `Video.collaboratorIds` — this is handled separately from the `VideoConfig` fields. The VideoConfig row itself does not store collaborator IDs. +- If `autoRender` is omitted or `false`, the description is not re-rendered immediately. The rendered description updates only when a sync job runs or the render queue processes a background render. + +--- + +## POST /video-configs/:videoId/render-preview + +Render the description using the provided config, without saving anything. Used by the video editor's live preview panel. + +**Request body:** Same shape as `PUT /video-configs/:videoId`. + +**Response:** + +```json +{ + "rendered": "Full rendered description string", + "hash": "sha256hex" +} +``` + +**Notes:** +- Nothing is persisted. The video's `renderedDescription`, `lastSyncedHash`, and `VideoConfig` row are all unchanged. +- The preview renders against live data: the video's current metadata, active campaigns, and team variables at the time of the call. +- `collaboratorIds` in the request body is used for the preview render directly — it does not need to match the currently saved `Video.collaboratorIds`. This allows the editor to preview with a collaborator selection before saving. +- Active campaign blocks are appended automatically if their date window is currently active. diff --git a/documentation/YouTube Studio Flow/04 - Design Guidelines/01 - Visual Design.md b/documentation/YouTube Studio Flow/04 - Design Guidelines/01 - Visual Design.md new file mode 100644 index 0000000..94b3fb6 --- /dev/null +++ b/documentation/YouTube Studio Flow/04 - Design Guidelines/01 - Visual Design.md @@ -0,0 +1,154 @@ +# 01 - Visual Design + +This document defines the visual language of YouTube Studio Flow. All UI work must follow these rules. For CSS implementation details, see [[02 - CSS Conventions]]. + +--- + +## Colors + +The app uses a warm off-white background with a teal primary color. Two themes are supported: light and dark. Both use the same CSS variable names — the values swap via a `[data-theme="dark"]` selector in `globals.css`. + +**Never use inline hex values.** Always reference the variables below. Test every new color usage in both themes before committing. + +### Light Theme + +| Variable | Value | Usage | +| --------------------------- | --------- | --------------------------------------------------------------------- | +| `--color-bg` | `#f7f6f2` | Page background | +| `--color-surface` | `#f9f8f5` | Card and panel backgrounds | +| `--color-surface-2` | `#fbfbf9` | Elevated surfaces (nested cards) | +| `--color-surface-offset` | `#f3f0ec` | Inset/recessed areas | +| `--color-border` | `#d4d1ca` | Borders | +| `--color-divider` | `#dcd9d5` | Subtle dividers between sections | +| `--color-text` | `#28251d` | Primary text | +| `--color-text-muted` | `#66645d` | Secondary/supporting text | +| `--color-text-faint` | `#9f9c94` | Placeholder and hint text | +| `--color-text-inverse` | `#f9f8f4` | Text placed on dark backgrounds | +| `--color-primary` | `#01696f` | Teal — primary actions, active nav, links | +| `--color-primary-hover` | `#0c4e54` | Primary button hover state | +| `--color-primary-highlight` | `#cedcd8` | Primary tint — active nav items, selection backgrounds | +| `--color-success` | `#437a22` | Success states | +| `--color-warning` | `#964219` | Warning states | +| `--color-error` | `#a12c7b` | Error states — magenta, not red | +| `--color-blue` | `#006494` | Info and link accent | +| `--color-purple` | `#7a39bb` | Secondary accent | + +### Dark Theme + +Dark theme uses the same variable names. Key differences: + +- **Surfaces** shift to near-black: `#171614`, `#1c1b19`, `#201f1d` +- **Primary** lightens for contrast: `#4f98a3` +- **Error, success, and warning** all lighten to maintain legibility on dark backgrounds + +When adding any new color usage, verify it works in both themes. The dark theme overrides are defined in `globals.css` under `[data-theme="dark"]`. + +### Semantic mapping quick reference + +| Situation | Variable | +|---|---| +| Page background | `--color-bg` | +| Card or panel | `--color-surface` | +| Input background | `--color-surface` | +| Sidebar / elevated container | `--color-surface-2` | +| Recessed input or inner area | `--color-surface-offset` | +| Standard border | `--color-border` | +| Divider line | `--color-divider` | +| Body copy | `--color-text` | +| Labels, captions | `--color-text-muted` | +| Placeholders | `--color-text-faint` | +| Primary button, active state | `--color-primary` | +| Hover on primary | `--color-primary-hover` | +| Active nav background | `--color-primary-highlight` | + +--- + +## Typography + +### Fonts + +| Role | Family | Fallback | CSS Variable | +|---|---|---|---| +| Body / UI | General Sans | Inter, sans-serif | `--font-body` | +| Display / Headings | Cabinet Grotesk | Inter, sans-serif | `--font-display` | + +Both fonts are loaded from the Fontshare CDN via `` in `layout.tsx`. Do not use system fonts for headings. + +### Type Scale + +All sizes use fluid `clamp()` values so text scales smoothly between viewport breakpoints. + +| Variable | Approximate range | Typical use | +|---|---|---| +| `--text-xs` | 0.75rem → 0.875rem | Badges, footnotes, timestamps | +| `--text-sm` | 0.875rem → 1rem | Labels, captions, secondary UI | +| `--text-base` | 1rem → 1.125rem | Body copy, inputs | +| `--text-lg` | 1.125rem → 1.5rem | Sub-headings, card titles | +| `--text-xl` | 1.5rem → 2.25rem | Section headings | +| `--text-2xl` | 2rem → 3.5rem | Page titles | + +### Usage rules + +- **Page titles** — `font-family: var(--font-display)`, `font-weight: 700`, `font-size: var(--text-xl)` or `--text-2xl` +- **Section labels** — `font-size: var(--text-sm)`, `text-transform: uppercase`, `letter-spacing: 0.05em`, `color: var(--color-text-muted)` +- **Body text** — `font-family: var(--font-body)`, `font-size: var(--text-base)`, `color: var(--color-text)` +- **Input text** — `font-size: var(--text-base)` or `--text-sm` + +Never set font sizes in raw `px` or `rem` values — always use the scale variables. + +--- + +## Spacing + +Use the spacing scale for all margins, padding, and gaps. Never use raw pixel values in component CSS. + +| Variable | Value | Rough equivalent | +|---|---|---| +| `--space-1` | `0.25rem` | 4px | +| `--space-2` | `0.5rem` | 8px | +| `--space-3` | `0.75rem` | 12px | +| `--space-4` | `1rem` | 16px | +| `--space-5` | `1.25rem` | 20px | +| `--space-6` | `1.5rem` | 24px | +| `--space-8` | `2rem` | 32px | +| `--space-10` | `2.5rem` | 40px | +| `--space-12` | `3rem` | 48px | +| `--space-16` | `4rem` | 64px | + +--- + +## Borders and Radius + +| Variable | Value | Typical use | +|---|---|---| +| `--radius-sm` | `0.375rem` | Small inputs, tight chips | +| `--radius-md` | `0.5rem` | Buttons, standard inputs | +| `--radius-lg` | `0.75rem` | Cards, panels | +| `--radius-xl` | `1rem` | Large cards, modals | +| `--radius-full` | `9999px` | Pills, badges, avatar circles, fully-round buttons | + +All bordered elements use `--color-border` for their border color unless a semantic variant applies (e.g. `--color-error` for invalid inputs). + +--- + +## Shadows + +| Variable | Typical use | +|---|---| +| `--shadow-sm` | Subtle card lift — separates surface from background | +| `--shadow-md` | Popovers and dropdowns | +| `--shadow-lg` | Modals and full-screen overlays | + +Use the lightest shadow that achieves the visual separation needed. Do not stack multiple shadows. + +--- + +## Layout Constants + +| Variable / Value | Definition | +|---|---| +| `--sidebar-width: 280px` | Expanded navigation sidebar | +| `--sidebar-width-collapsed: 64px` | Collapsed sidebar (icon-only) | +| `--header-height: 72px` | Top bar height | + +The main content area is offset by the sidebar width. See [[03 - Component Patterns]] for the sidebar collapse pattern. diff --git a/documentation/YouTube Studio Flow/04 - Design Guidelines/02 - CSS Conventions.md b/documentation/YouTube Studio Flow/04 - Design Guidelines/02 - CSS Conventions.md new file mode 100644 index 0000000..20a4ca3 --- /dev/null +++ b/documentation/YouTube Studio Flow/04 - Design Guidelines/02 - CSS Conventions.md @@ -0,0 +1,188 @@ +# 02 - CSS Conventions + +Rules for writing CSS in YouTube Studio Flow. These apply to all frontend work. For the token values these rules reference, see [[01 - Visual Design]]. + +--- + +## Always use CSS Modules + +Every component gets its own `ComponentName.module.css` file, co-located with the component. Class names are consumed as: + +```tsx +import styles from './ComponentName.module.css'; + +
...
+``` + +Global styles live exclusively in `globals.css`. Do not add component-specific rules to global files. + +--- + +## Never use inline hex colors + +All color values must come from CSS variables defined in `globals.css`. This is what makes the dark theme work — swapping variable values at the root switches the entire app. + +```css +/* WRONG */ +color: #01696f; +background: #f9f8f5; +border: 1px solid #d4d1ca; + +/* CORRECT */ +color: var(--color-primary); +background: var(--color-surface); +border: 1px solid var(--color-border); +``` + +This applies everywhere: component CSS, inline styles, and any dynamically constructed style objects. + +--- + +## Never use raw px for spacing + +Use `--space-*` variables for all margins, padding, and gaps. Raw pixel values make the spacing system incoherent and break visual rhythm. + +```css +/* WRONG */ +padding: 16px 24px; +gap: 8px; +margin-bottom: 12px; + +/* CORRECT */ +padding: var(--space-4) var(--space-6); +gap: var(--space-2); +margin-bottom: var(--space-3); +``` + +The spacing scale runs from `--space-1` (0.25rem) to `--space-16` (4rem). See [[01 - Visual Design]] for the full table. + +--- + +## The `min-width: 0` rule + +Flex and grid children default to `min-width: auto`, which means they cannot shrink below their content's natural size. In practice this causes horizontal overflow — the child pushes past its container instead of wrapping or truncating. + +Add `min-width: 0` to any flex or grid child that contains text, a table, a wide image, or another flex/grid container: + +```css +.wrapper { + display: flex; +} + +.main { + flex: 1; + min-width: 0; /* required — prevents horizontal scrollbar */ +} +``` + +This must be applied at every level of nesting. A `min-width: 0` on an outer element does not propagate to inner flex containers. + +--- + +## The `overflow-x: hidden` backstop + +The `.content` wrapper in `DashboardLayout.module.css` carries `overflow-x: hidden` as a definitive backstop against page-level horizontal overflow. Do not remove this rule. It is a last-resort containment boundary, not a substitute for fixing `min-width` at the source. + +--- + +## Global utility classes + +Prefer these over writing new styles for common UI elements. + +### Buttons — from `globals.css` + +```html + + +``` + +Do not write custom button styles for standard primary/secondary actions. + +### Pills and badges — from `globals.css` + +```html +Active +Warning +Draft +``` + +Pills use `--radius-full` and are intended for status chips, labels, and category tags. + +### Form fields — from `FormField.module.css` + +Import this file as `f` by convention: + +```tsx +import f from '@/components/shared/FormField.module.css'; +``` + +Available classes: + +```tsx +
+ + +
+ +
+ {/* two fields side by side */} +
+ +
+ {/* right-aligned action buttons */} +
+``` + +| Class | Purpose | +|---|---| +| `f.field` | Vertical label + input stack | +| `f.label` | Styled form label | +| `f.input` | Standard text input | +| `f.select` | Dropdown/select element | +| `f.row` | Horizontal pair of fields | +| `f.actions` | Right-aligned button row | + +--- + +## Dropdowns and selects + +Use `className={f.select}` from `FormField.module.css`. When a select needs to be inline or auto-sized, override width only: + +```tsx + handleFilter({ entityType: e.target.value })} + > + + {ENTITY_TYPES.map((t) => )} + + + + + {(entityType || action) && ( + + )} + + {data && ( + {data.total.toLocaleString()} events + )} + + + {isLoading && ( +
Loading history…
+ )} + {isError && ( +
Failed to load audit logs.
+ )} + + {data && data.data.length === 0 && ( +
No activity found{entityType || action ? ' for this filter' : ''}.
+ )} + + {data && data.data.length > 0 && ( +
+ + + + + + + + + + + + {data.data.map((log) => )} + +
TimeActorEntity TypeEntity IDAction +
+
+ )} + + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ )} + + ); +} diff --git a/frontend/src/app/(dashboard)/blocks/page.module.css b/frontend/src/app/(dashboard)/blocks/page.module.css new file mode 100644 index 0000000..6d8ace3 --- /dev/null +++ b/frontend/src/app/(dashboard)/blocks/page.module.css @@ -0,0 +1,547 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--space-8); + max-width: 1400px; + margin: 0 auto; +} + +.header { + display: flex; + justify-content: space-between; + align-items: flex-end; +} + +.eyebrow { + display: block; + font-size: var(--text-xs); + font-weight: 700; + color: var(--color-primary); + text-transform: uppercase; + letter-spacing: .08em; + margin-bottom: var(--space-1); +} + +.headerLeft h1 { + font-family: var(--font-display); + font-size: var(--text-xl); + font-weight: 700; + line-height: 1.1; +} + +.headerRight { + display: flex; + gap: var(--space-3); +} + +.controls { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-6); +} + +.searchBar { + display: flex; + align-items: center; + gap: var(--space-3); + background: var(--color-surface); + border: 1px solid var(--color-border); + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + flex: 1; + max-width: 480px; + color: var(--color-text-faint); +} + +.searchBar input { + background: none; + border: none; + color: var(--color-text); + outline: none; + flex: 1; + font-size: var(--text-sm); +} + +.filterRow { + display: flex; + align-items: center; + gap: var(--space-4); +} + +.filterBtn { + display: flex; + align-items: center; + gap: var(--space-2); + color: var(--color-text-muted); + font-size: var(--text-sm); + font-weight: 600; +} + +.quickFilters { + display: flex; + gap: var(--space-2); +} + +.tag { + font-size: var(--text-xs); + font-weight: 600; + padding: 0.25rem 0.75rem; + background: var(--color-surface-offset); + border: 1px solid var(--color-border); + border-radius: var(--radius-full); + color: var(--color-text-muted); + cursor: pointer; +} + +.tag:hover { + background: var(--color-divider); + color: var(--color-text); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); + gap: var(--space-6); +} + +.card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-5); + box-shadow: var(--shadow-sm); + transition: transform 0.2s, box-shadow 0.2s; +} + +.card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.cardHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.titleInfo { + display: flex; + flex-direction: column; + gap: 2px; +} + +.blockName { + font-size: var(--text-base); + font-weight: 700; + color: var(--color-text); +} + +.blockDesc { + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +.previewBox { + background: var(--color-surface-offset); + border: 1px solid var(--color-divider); + border-radius: var(--radius-md); + padding: var(--space-4); +} + +.preview { + font-family: var(--font-body); + font-size: var(--text-xs); + color: var(--color-text-muted); + white-space: pre-wrap; + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.5; +} + +.cardFooter { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: auto; +} + +.usage { + font-size: var(--text-xs); + color: var(--color-text-faint); +} + +.usage strong { + color: var(--color-text-muted); +} + +.editBtn { + font-size: var(--text-xs); + font-weight: 700; + color: var(--color-primary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.count { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: var(--space-3); + padding: 0.1rem 0.5rem; + background: var(--color-surface-offset); + border: 1px solid var(--color-border); + border-radius: var(--radius-full); + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text-muted); + vertical-align: middle; +} + +.tagActive { + background: var(--color-primary-highlight); + border-color: var(--color-primary); + color: var(--color-primary); +} + +.tags { + display: flex; + gap: var(--space-1); + flex-wrap: wrap; +} + +.blockTag { + font-size: 10px; + font-weight: 600; + padding: 0.15rem 0.5rem; + background: var(--color-surface-offset); + border: 1px solid var(--color-divider); + border-radius: var(--radius-full); + color: var(--color-text-faint); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.state { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-3); + padding: var(--space-16); + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +/* ─── Block modal 2-column layout ──────────────────────────────────────── */ + +.modalGrid { + display: grid; + grid-template-columns: 1fr 340px; + gap: var(--space-6); + align-items: start; +} + +.modalLeft { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.modalRight { + display: flex; + flex-direction: column; + gap: var(--space-4); + position: sticky; + top: 0; +} + +.varDeclSection { + display: flex; + flex-direction: column; + gap: var(--space-3); + background: var(--color-surface-offset); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-4); +} + +.varDeclTitle { + font-size: var(--text-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-faint); +} + +.varDeclHint { + font-size: var(--text-xs); + color: var(--color-text-faint); + line-height: 1.4; +} + +/* Variable reference panel */ +.varPanel { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin-top: 0; + padding: var(--space-3); + background: var(--color-surface-offset); + border: 1px solid var(--color-divider); + border-radius: var(--radius-md); +} + +.varGroup { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.varGroupLabel { + font-size: var(--text-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-faint); +} + +.varChips { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.varChip { + font-size: var(--text-xs); + font-family: var(--font-mono, monospace); + padding: 2px 8px; + border-radius: var(--radius-full, 999px); + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-primary); + cursor: pointer; + transition: background 0.12s, border-color 0.12s; + white-space: nowrap; + display: flex; + flex-direction: column; + align-items: flex-start; + border-radius: var(--radius-md); + white-space: normal; +} + +.varChipLabel { + font-family: var(--font-sans, sans-serif); + font-size: 10px; + color: var(--color-text-faint); + margin-top: 1px; +} + +.varChip:hover { + background: color-mix(in srgb, var(--color-primary) 10%, transparent); + border-color: var(--color-primary); +} + +.varChipCollab { + color: var(--color-purple, #a855f7); + border-color: color-mix(in srgb, var(--color-purple, #a855f7) 40%, transparent); +} + +.varChipCollab:hover { + background: color-mix(in srgb, var(--color-purple, #a855f7) 10%, transparent); + border-color: var(--color-purple, #a855f7); +} + +.trashBtn { + color: var(--color-text-faint); + display: flex; + align-items: center; + transition: color 0.12s; +} +.trashBtn:hover { color: var(--color-error); } + +.confirmDelete { + display: flex; + align-items: center; + gap: var(--space-2); + white-space: nowrap; +} +.confirmText { font-size: var(--text-xs); color: var(--color-text-muted); } +.deleteErr { font-size: var(--text-xs); color: var(--color-error); max-width: 200px; } +.confirmYes { + font-size: var(--text-xs); + font-weight: 700; + color: white; + background: var(--color-error); + border-radius: var(--radius-sm); + padding: 2px 8px; +} +.confirmNo { + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-text-muted); +} + +@keyframes spin { to { transform: rotate(360deg); } } +.spinner { animation: spin 1s linear infinite; } + +/* Type description below the select */ +.typeDesc { + font-size: var(--text-xs); + color: var(--color-text-faint); + line-height: 1.4; + margin-top: var(--space-1); +} + +/* Card type hint */ +.typeHint { + font-size: var(--text-xs); + color: var(--color-text-faint); + line-height: 1.4; + margin-top: calc(-1 * var(--space-2)); +} + +/* ─── Condition builder ──────────────────────────────────────────────────── */ + +.conditionBuilder { + display: flex; + flex-direction: column; + gap: var(--space-2); + background: var(--color-surface-offset); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-3); +} + +.conditionHeader { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.conditionLabel { + font-size: var(--text-xs); + color: var(--color-text-muted); + font-weight: 600; +} + +.combinatorSelect { + padding: 2px var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + font-size: var(--text-xs); + font-weight: 700; + color: var(--color-primary); + cursor: pointer; +} + +.conditionEmpty { + font-size: var(--text-xs); + color: var(--color-text-faint); + font-style: italic; +} + +.ruleList { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.ruleRow { + display: flex; + align-items: center; + gap: var(--space-2); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); + flex-wrap: wrap; +} + +.ruleTypeSelect { + padding: 3px var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-offset); + font-size: var(--text-xs); + color: var(--color-text); + cursor: pointer; +} + +.ruleInput { + flex: 1; + min-width: 120px; + padding: 3px var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-offset); + font-size: var(--text-xs); + color: var(--color-text); + outline: none; +} + +.ruleInput:focus { border-color: var(--color-primary); } + +.ruleNumInput { + width: 60px; + padding: 3px var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-offset); + font-size: var(--text-xs); + color: var(--color-text); + outline: none; + text-align: center; +} + +.ruleNumInput:focus { border-color: var(--color-primary); } + +.ruleValueLabel { + font-size: var(--text-xs); + color: var(--color-text-faint); + white-space: nowrap; +} + +.ruleRemoveBtn { + margin-left: auto; + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: var(--radius-sm); + color: var(--color-text-faint); + flex-shrink: 0; + transition: all 0.15s; +} + +.ruleRemoveBtn:hover { + background: color-mix(in srgb, var(--color-error), transparent 85%); + color: var(--color-error); +} + +.addRuleBtn { + display: inline-flex; + align-items: center; + gap: var(--space-1); + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-text-muted); + padding: var(--space-1) var(--space-2); + border: 1px dashed var(--color-border); + border-radius: var(--radius-sm); + align-self: flex-start; + transition: all 0.15s; +} + +.addRuleBtn:hover { + border-color: var(--color-primary); + color: var(--color-primary); + background: var(--color-primary-highlight); +} diff --git a/frontend/src/app/(dashboard)/blocks/page.tsx b/frontend/src/app/(dashboard)/blocks/page.tsx new file mode 100644 index 0000000..efb0252 --- /dev/null +++ b/frontend/src/app/(dashboard)/blocks/page.tsx @@ -0,0 +1,556 @@ +'use client'; + +import { useState, useRef } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Search, ListFilter, Loader2, AlertCircle, Trash2, X } from 'lucide-react'; +import { + fetchBlocks, createBlock, updateBlock, deleteBlock, fetchBlockUsage, + fetchTeamVariables, fetchSystemVariables, fetchCampaigns, + type Block, type BlockPayload, type BlockVariableDefinition, + type TeamVariable, type SystemVariable, type Campaign, + type BlockCondition, type ConditionRule, type CollabCountRule, +} from '@/lib/api'; +import UsagePanel from '@/components/shared/UsagePanel'; +import Modal from '@/components/shared/Modal'; +import f from '@/components/shared/FormField.module.css'; +import styles from './page.module.css'; + +const TYPE_META: Record = { + STATIC: { label: 'Static', pill: 'pill-blue', desc: 'Output verbatim — no token substitution of any kind. Use for boilerplate text that must never be altered.' }, + VARIABLE: { label: 'Variable', pill: 'pill-warn', desc: 'Text that changes per video. Variables like {sponsor_name} are filled in when the description is rendered.' }, + CONDITIONAL: { label: 'Conditional', pill: 'pill-primary', desc: 'Only included when all (or any) of its conditions are met. Define conditions below.' }, + COLLABORATOR:{ label: 'Collaborator', pill: 'pill-purple', desc: 'Repeats once per collaborator linked to the video, filling in {collab.name}, {collab.youtube}, etc.' }, + CAMPAIGN: { label: 'Campaign', pill: 'pill-warn', desc: 'Auto-included in every render while the linked campaign\'s date window is active.' }, +}; + +const BLOCK_TYPES = Object.keys(TYPE_META); +const TYPE_FILTERS = ['All', 'Static', 'Variable', 'Global', 'Conditional'] as const; + +function TypePill({ type }: { type: string }) { + const meta = TYPE_META[type]; + return {meta?.label ?? type}; +} + +// ─── Condition builder ──────────────────────────────────────────────────────── + +const OPERATOR_LABELS: Record = { + eq: 'is exactly', gt: 'more than', lt: 'fewer than', gte: 'at least', lte: 'at most', +}; + +const EMPTY_CONDITION: BlockCondition = { combinator: 'and', rules: [] }; + +function emptyRule(): ConditionRule { + return { type: 'variable_filled', variable: '' }; +} + +function ConditionBuilder({ + condition, + onChange, + teamVars, +}: { + condition: BlockCondition; + onChange: (c: BlockCondition) => void; + teamVars: TeamVariable[]; +}) { + const setCombinator = (v: 'and' | 'or') => onChange({ ...condition, combinator: v }); + + const addRule = () => onChange({ ...condition, rules: [...condition.rules, emptyRule()] }); + + const removeRule = (i: number) => + onChange({ ...condition, rules: condition.rules.filter((_, idx) => idx !== i) }); + + const updateRule = (i: number, patch: Partial) => + onChange({ + ...condition, + rules: condition.rules.map((r, idx) => + idx === i ? { ...r, ...patch } as ConditionRule : r, + ), + }); + + const changeRuleType = (i: number, type: ConditionRule['type']) => { + if (type === 'collab_count') { + updateRule(i, { type, operator: 'eq', value: 1 } as Partial); + } else { + updateRule(i, { type, variable: '' } as Partial); + } + }; + + return ( +
+
+ Show this block when + + of these conditions are met +
+ + {condition.rules.length === 0 && ( +

No conditions — block is always included when active.

+ )} + +
+ {condition.rules.map((rule, i) => ( +
+ + + {(rule.type === 'variable_filled' || rule.type === 'variable_empty') && ( + <> + updateRule(i, { variable: e.target.value } as Partial)} + placeholder="variable_name" + /> + + {teamVars.map((v) => + + {rule.type === 'variable_filled' ? 'is filled' : 'is empty'} + + + )} + + {rule.type === 'collab_count' && ( + <> + count is + + updateRule(i, { value: Number(e.target.value) })} + /> + + )} + + +
+ ))} +
+ + +
+ ); +} + +// ─── Variable reference panel ───────────────────────────────────────────────── + +function VarDefEditor({ defs, onChange }: { defs: BlockVariableDefinition[]; onChange: (d: BlockVariableDefinition[]) => void }) { + const add = () => onChange([...defs, { name: '', label: '', description: '', defaultValue: '' }]); + const remove = (i: number) => onChange(defs.filter((_, idx) => idx !== i)); + const update = (i: number, key: keyof BlockVariableDefinition, val: string) => { + const next = defs.map((d, idx) => idx === i ? { ...d, [key]: val } : d); + onChange(next); + }; + return ( +
+ {defs.map((d, i) => ( +
+
+
+ + update(i, 'name', e.target.value)} placeholder="sponsor_name" /> +
+
+ + update(i, 'label', e.target.value)} placeholder="Sponsor Name" /> +
+
+
+
+ + update(i, 'description', e.target.value)} placeholder="Shown to editors" /> +
+
+ + update(i, 'defaultValue', e.target.value)} placeholder="e.g. MegaCorp" /> +
+
+ +
+ ))} + +
+ ); +} + +function VariableReferencePanel({ + teamVars, + systemVars, + onInsert, +}: { + teamVars: TeamVariable[]; + systemVars: SystemVariable[]; + onInsert: (token: string) => void; +}) { + const collabVars = systemVars.filter((v) => v.group === 'collaborator'); + const videoVars = systemVars.filter((v) => v.group === 'video'); + return ( +
+ {teamVars.length > 0 && ( +
+ Global Variables +
+ {teamVars.map((v) => ( + + ))} +
+
+ )} + {videoVars.length > 0 && ( +
+ Video Variables +
+ {videoVars.map((v) => ( + + ))} +
+
+ )} + {collabVars.length > 0 && ( +
+ Collaborator Variables +
+ {collabVars.map((v) => ( + + ))} +
+
+ )} +
+ ); +} + +// ─── Block modal ────────────────────────────────────────────────────────────── + +const EMPTY_FORM: BlockPayload = { + name: '', type: 'STATIC', content: '', language: 'de', + tags: [], active: true, compact: false, variableDefinitions: [], campaignId: null, condition: null, +}; + +function BlockModal({ initial, onClose }: { initial?: Block; onClose: () => void }) { + const qc = useQueryClient(); + + const initForm = (): BlockPayload => { + if (!initial) return EMPTY_FORM; + return { + name: initial.name, type: initial.type, content: initial.content, + language: initial.language, tags: initial.tags, active: initial.active, + compact: initial.compact, variableDefinitions: initial.variableDefinitions ?? [], + campaignId: initial.campaignId ?? null, + condition: initial.type === 'CONDITIONAL' ? (initial.condition ?? EMPTY_CONDITION) : null, + }; + }; + + const [form, setForm] = useState(initForm); + const [tagsInput, setTagsInput] = useState(initial?.tags.join(', ') ?? ''); + const textareaRef = useRef(null); + + const { data: teamVars = [] } = useQuery({ queryKey: ['team-variables'], queryFn: fetchTeamVariables }); + const { data: systemVars = [] } = useQuery({ queryKey: ['system-variables'], queryFn: fetchSystemVariables }); + const { data: campaigns = [] } = useQuery({ queryKey: ['campaigns'], queryFn: fetchCampaigns }); + const { data: usage, isLoading: usageLoading } = useQuery({ + queryKey: ['block-usage', initial?.id], + queryFn: () => fetchBlockUsage(initial!.id), + enabled: !!initial, + }); + + const mutation = useMutation({ + mutationFn: () => { + const payload = { ...form, tags: tagsInput.split(',').map((t) => t.trim()).filter(Boolean) }; + return initial ? updateBlock(initial.id, payload) : createBlock(payload); + }, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['blocks'] }); onClose(); }, + }); + + const set = (key: keyof BlockPayload, val: unknown) => setForm((p) => ({ ...p, [key]: val })); + + const handleTypeChange = (type: string) => { + set('type', type); + if (type === 'CONDITIONAL' && !form.condition) { + set('condition', EMPTY_CONDITION); + } else if (type !== 'CONDITIONAL') { + set('condition', null); + } + }; + + const insertToken = (token: string) => { + const el = textareaRef.current; + if (!el) return; + const start = el.selectionStart ?? form.content.length; + const end = el.selectionEnd ?? start; + const next = form.content.slice(0, start) + token + form.content.slice(end); + set('content', next); + requestAnimationFrame(() => { + el.focus(); + el.setSelectionRange(start + token.length, start + token.length); + }); + }; + + return ( + +
+ {/* ── Left column: content editing ── */} +
+
+ + set('name', e.target.value)} placeholder="e.g. Sponsor CTA" autoFocus /> +
+
+
+ + + {form.type &&

{TYPE_META[form.type]?.desc}

} +
+
+ + +
+
+ + {form.type === 'CAMPAIGN' && ( +
+ + +

This block auto-appears in every render while the campaign's date window is active.

+
+ )} + + {form.type === 'CONDITIONAL' && ( +
+ + set('condition', c)} + teamVars={teamVars} + /> +
+ )} + +
+ +