From c5cbe9df72a1ca27f8cf83bb9650982c7a90936f Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Sat, 11 Apr 2026 20:23:46 +0300 Subject: [PATCH 1/2] add random endpoint --- backend/backlog_app/api/crud.py | 31 ++++++++++++++++++++++ backend/backlog_app/api/view/movie_view.py | 21 ++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/backend/backlog_app/api/crud.py b/backend/backlog_app/api/crud.py index cd18b8a..1691b20 100644 --- a/backend/backlog_app/api/crud.py +++ b/backend/backlog_app/api/crud.py @@ -2,6 +2,7 @@ from uuid import UUID from fastapi import HTTPException +from sqlalchemy import or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload @@ -42,6 +43,36 @@ async def get_movies(db: AsyncSession, user_id: str | None = None) -> MovieList: return MovieList.model_validate({"movies": movies}) +async def get_random_movie_pool( + db: AsyncSession, + user_id: UUID, + exclude_ids: list[int] | None = None, +) -> list[Movie]: + if exclude_ids is None: + exclude_ids = [] + + query = ( + select(Movie) + .options(selectinload(Movie.user)) + .where( + or_(Movie.published.is_(True), Movie.user_id == user_id), + Movie.watched.is_(False), + ) + ) + + if exclude_ids: + query = query.where(Movie.id.notin_(exclude_ids)) + + result = await db.execute(query) + pool = result.scalars().all() + + logger.debug( + "Random movie pool size: %s (excluded: %s)", len(pool), len(exclude_ids) + ) + + return list(pool) + + async def get_movie_by_id( db: AsyncSession, movie_id: int, user_id: UUID | None = None ) -> MovieRead | None: diff --git a/backend/backlog_app/api/view/movie_view.py b/backend/backlog_app/api/view/movie_view.py index 4aade70..684f76f 100644 --- a/backend/backlog_app/api/view/movie_view.py +++ b/backend/backlog_app/api/view/movie_view.py @@ -1,6 +1,7 @@ +import random as stdlib_random from typing import Annotated -from fastapi import APIRouter, BackgroundTasks, Depends, status +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession from backlog_app.api import crud @@ -40,6 +41,24 @@ async def get_movie_list( return movies +@router.get("/random", response_model=MovieRead) +async def get_random_movie( + db: Annotated[AsyncSession, Depends(get_async_session)], + user: Annotated[User, Depends(current_active_user)], + exclude_ids: list[int] = Query(default=[]), +): + pool = await crud.get_random_movie_pool(db, user.id, exclude_ids) + + if not pool: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No unwatched movies available", + ) + + chosen = stdlib_random.choice(pool) + return chosen + + @router.get("/{movie_id}", response_model=MovieRead) async def get_movie_by_id( movie_id: int, From a8644f8ba602f2129af3045b728526e680895aa5 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Sat, 11 Apr 2026 20:24:36 +0300 Subject: [PATCH 2/2] add new button for movie roulette feature --- frontend/src/api/movies.ts | 7 + .../ui}/MovieListView.vue | 47 +++- frontend/src/components/ui/MovieRoulette.vue | 230 ++++++++++++++++++ frontend/src/composables/useMovieRoulette.ts | 73 ++++++ frontend/src/router/index.ts | 2 +- 5 files changed, 347 insertions(+), 12 deletions(-) rename frontend/src/{views/movies => components/ui}/MovieListView.vue (84%) create mode 100644 frontend/src/components/ui/MovieRoulette.vue create mode 100644 frontend/src/composables/useMovieRoulette.ts diff --git a/frontend/src/api/movies.ts b/frontend/src/api/movies.ts index c4bc82e..9ddbd53 100644 --- a/frontend/src/api/movies.ts +++ b/frontend/src/api/movies.ts @@ -52,6 +52,13 @@ export const moviesApi = { return data }, + async getRandom(excludeIds: number[] = []): Promise { + const params = new URLSearchParams() + excludeIds.forEach((id) => params.append('exclude_ids', String(id))) + const { data } = await api.get('/movies/random', { params }) + return data + }, + async create(movie: MovieCreate): Promise { const { data } = await api.post('/movies/', movie) return data diff --git a/frontend/src/views/movies/MovieListView.vue b/frontend/src/components/ui/MovieListView.vue similarity index 84% rename from frontend/src/views/movies/MovieListView.vue rename to frontend/src/components/ui/MovieListView.vue index bdb3fa0..d0acf25 100644 --- a/frontend/src/views/movies/MovieListView.vue +++ b/frontend/src/components/ui/MovieListView.vue @@ -9,12 +9,21 @@

Коллекция

Мои фильмы

- + +
+ + + + +
@@ -37,8 +46,8 @@
- -
+ + @@ -95,6 +104,18 @@ @submit="handleMovieSubmit" /> + + + @@ -118,17 +139,21 @@ + + diff --git a/frontend/src/composables/useMovieRoulette.ts b/frontend/src/composables/useMovieRoulette.ts new file mode 100644 index 0000000..a8ec7fa --- /dev/null +++ b/frontend/src/composables/useMovieRoulette.ts @@ -0,0 +1,73 @@ +import { ref } from 'vue' +import { moviesApi, type MovieRead } from '@/api/movies.ts' + +export function useMovieRoulette() { + const pickedMovie = ref(null) + const excludedIds = ref([]) + const loading = ref(false) + const exhausted = ref(false) + const isOpen = ref(false) + + async function spin() { + loading.value = true + exhausted.value = false + + try { + const movie = await moviesApi.getRandom(excludedIds.value) + pickedMovie.value = movie + } catch (e: unknown) { + const err = e as { response?: { status?: number } } + if (err.response?.status === 404) { + exhausted.value = true + pickedMovie.value = null + } + } finally { + loading.value = false + } + } + + // Убрать текущий фильм из пула и сразу крутить снова + async function reject() { + if (pickedMovie.value) { + excludedIds.value.push(pickedMovie.value.id) + pickedMovie.value = null + await spin() + } + } + + // Открыть рулетку и сразу сделать первый выбор + async function open() { + isOpen.value = true + await spin() + } + + // Закрыть и сбросить всё состояние + function close() { + isOpen.value = false + pickedMovie.value = null + excludedIds.value = [] + exhausted.value = false + loading.value = false + } + + // Начать заново, не закрывая модалку + async function restart() { + excludedIds.value = [] + exhausted.value = false + pickedMovie.value = null + await spin() + } + + return { + pickedMovie, + excludedIds, + loading, + exhausted, + isOpen, + open, + close, + spin, + reject, + restart, + } +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b940423..9ab10b5 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -49,7 +49,7 @@ const router = createRouter({ { path: '/movies', name: 'movies', - component: () => import('@/views/movies/MovieListView.vue'), + component: () => import('@/components/ui/MovieListView.vue'), meta: { requiresAuth: true }, }, {