# -*- coding: utf-8 -*-
"""ADO2 영상 + 썰박스 콘텐츠 통합 목록.
`GET /video/all`(전체 갤러리)과 `GET /archive/videos/`(내 콘텐츠)가 두 종류를
**하나의 정렬·페이징된 목록**으로 내려주기 위한 공용 쿼리다.
**왜 프론트에서 못 합치나**: 정렬·검색·지역 필터·페이지네이션이 전부 서버사이드다.
두 API 를 각각 호출해 이어붙이면 1페이지에 ADO2 12개 + 썰박스 12개가 각자 안에서만
정렬된 채 섞이고 `total`/`has_next` 도 어긋난다.
**왜 테이블을 안 합치나**: `video` 는 `project_id`/`lyric_id`/`song_id` 가 NOT NULL 이고
소유자·업장명이 `project` 에 있다. 썰박스를 넣으려면 그 4개를 nullable 로 열고
`Video` 를 참조하는 코드 72곳을 전부 감사해야 한다(2026-07-30 조사).
여기 사는 이유: 두 엔드포인트(`app/video`, `app/archive`)가 함께 쓰는데 엔드포인트
소유가 video 쪽이라 여기 뒀다. `app/archive` 는 이미 `app.video.models` 를 import 한다.
## 성능 설계 — 카운트를 UNION 안에 넣지 않는다
UNION 은 양쪽 브랜치를 **먼저 구체화**하므로, select 목록에 상관 서브쿼리를 두면
페이지 12건이 아니라 **매칭된 전체 행**에 대해 평가된다. 그래서
`created_at` 정렬(기본)에서는 카운트를 빼고, 페이지가 확정된 뒤 그 12건만 집계한다.
좋아요·댓글 수로 **정렬할 때만** 어쩔 수 없이 브랜치 안에서 계산한다.
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Optional
from sqlalchemy import Select, func, literal, null, or_, select, union_all
from sqlalchemy.ext.asyncio import AsyncSession
from app.comment.models import Comment
from app.database.like_cache import (
CT_SSUL,
CT_VIDEO,
backfill_user_set,
bulk_is_user_liked,
get_like_counts,
mset_like_counts,
)
from app.home.models import Project
from app.p2v.models import P2vF1Job, P2vF2Job
from app.ssulbox.models import SsulContent
from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES
from app.video.models import Video, VideoReaction
#: p2v_video(무빙 포스터 영상)·p2v_poster(스타일링 이미지). 내 콘텐츠와 갤러리
#: 양쪽에 나오지만(2026-08-26 갤러리 합류) **좋아요·댓글 축은 없다** — enrich 는
#: CT_VIDEO/CT_SSUL 만 돌아 0 으로 남고, 프론트도 P2V 카드에는 소셜 액션을 숨긴다.
#: 갤러리의 F2 는 퍼블릭 도메인 템플릿 결과물만 나간다(fetch_gallery 참조).
ContentType = Literal["video", "ssul", "p2v_video", "p2v_poster"]
CT_P2V_VIDEO = "p2v_video"
CT_P2V_POSTER = "p2v_poster"
#: 정렬 가능한 키. 그 외 값은 created_at 으로 떨어진다.
SORT_CREATED = "created_at"
SORT_LIKE = "like_count"
SORT_COMMENT = "comment_count"
@dataclass(slots=True)
class UnifiedItem:
"""통합 목록의 한 항목.
`id` 는 종류마다 **독립적인 시퀀스**다(`video.id` 와 `ssul_content.id` 모두 1부터
시작한다). 따라서 식별자는 반드시 `(ctype, id)` 쌍으로 다뤄야 한다 —
한 곳이라도 id 만 쓰면 다른 콘텐츠가 열린다.
"""
ctype: ContentType
id: int
store_name: str
region: Optional[str]
movie_url: str
created_at: datetime
#: ADO2 전용. 썰박스는 task_id 개념이 없어 빈 문자열이다
#: (`ssul_content` 는 task/content 를 한 테이블로 합쳤고 크레딧 앵커는 id 다).
task_id: str = ""
like_count: int = 0
comment_count: int = 0
is_liked_by_me: bool = False
poster_url: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
hashtags: Optional[list] = None
# ──────────────────────────────────────────────
# 상관 서브쿼리 (정렬용)
# ──────────────────────────────────────────────
def _video_like_subq():
return (
select(func.count(VideoReaction.id))
.where(VideoReaction.video_id == Video.id)
.correlate(Video)
.scalar_subquery()
)
def _video_comment_subq():
return (
select(func.count(Comment.id))
.where(Comment.video_id == Video.id, Comment.is_deleted.is_(False))
.correlate(Video)
.scalar_subquery()
)
def _ssul_like_subq():
# 좋아요·댓글은 castad 테이블에 합쳤다(2026-07-30). 썰박스 행은 video_id 대신
# content_id 가 채워져 있다 — 테이블은 같고 컬럼만 다르다.
return (
select(func.count(VideoReaction.id))
.where(VideoReaction.content_id == SsulContent.id)
.correlate(SsulContent)
.scalar_subquery()
)
def _ssul_comment_subq():
return (
select(func.count(Comment.id))
.where(
Comment.content_id == SsulContent.id,
Comment.is_deleted.is_(False),
)
.correlate(SsulContent)
.scalar_subquery()
)
# ──────────────────────────────────────────────
# 필터
# ──────────────────────────────────────────────
def _region_clause(region_col, detail_col, region: str):
"""castad `/video/all` 과 **동일한 규칙**으로 지역을 거른다.
시/도 이름이면 그 안의 시·군 목록으로 매칭하고, 동시에 상세 주소를 별칭으로
부분 일치 검색한다. 이 두 경로를 맞추지 않으면 같은 검색어에 ADO2 는 나오고
썰박스는 안 나오는 비대칭이 생긴다.
"""
cities = SIDO_CITIES.get(region)
if cities:
aliases = SIDO_SEARCH_ALIASES.get(region, [region])
return or_(
region_col.in_(cities),
*[detail_col.ilike(f"%{a}%") for a in aliases],
)
return or_(
region_col.ilike(f"%{region}%"),
detail_col.ilike(f"%{region}%"),
)
def _video_where(store_name: Optional[str], region: Optional[str]) -> list:
clauses = [
Video.status == "completed",
Video.is_deleted.is_(False),
Project.is_deleted.is_(False),
Video.result_movie_url.is_not(None),
]
if store_name:
clauses.append(Project.store_name.ilike(f"%{store_name}%"))
if region:
clauses.append(
_region_clause(Project.region, Project.detail_region_info, region)
)
return clauses
def _ssul_where(store_name: Optional[str], region: Optional[str]) -> list:
clauses = [
SsulContent.status == "done",
SsulContent.is_deleted.is_(False),
SsulContent.video_url.is_not(None),
]
if store_name:
clauses.append(SsulContent.store_name.ilike(f"%{store_name}%"))
if region:
clauses.append(
_region_clause(
SsulContent.region, SsulContent.detail_region_info, region
)
)
return clauses
# ──────────────────────────────────────────────
# 브랜치
# ──────────────────────────────────────────────
def _video_branch(where: list, sort_by: str) -> Select:
cols = [
literal(CT_VIDEO).label("ctype"),
Video.id.label("cid"),
Project.store_name.label("store_name"),
Project.region.label("region"),
Video.result_movie_url.label("movie_url"),
Video.created_at.label("created_at"),
Video.task_id.label("task_id"),
Video.poster_url.label("poster_url"),
Video.title.label("title"),
Video.description.label("description"),
Video.hashtags.label("hashtags"),
]
if sort_by == SORT_LIKE:
cols.append(_video_like_subq().label("sort_value"))
elif sort_by == SORT_COMMENT:
cols.append(_video_comment_subq().label("sort_value"))
return select(*cols).join(Project, Video.project_id == Project.id).where(*where)
def _ssul_branch(where: list, sort_by: str) -> Select:
cols = [
literal(CT_SSUL).label("ctype"),
SsulContent.id.label("cid"),
SsulContent.store_name.label("store_name"),
SsulContent.region.label("region"),
SsulContent.video_url.label("movie_url"),
SsulContent.created_at.label("created_at"),
# UNION 은 컬럼 수·순서가 양쪽 같아야 한다. 썰박스에는 task_id 가 없다.
literal("").label("task_id"),
SsulContent.poster_url.label("poster_url"),
SsulContent.title.label("title"),
SsulContent.description.label("description"),
SsulContent.hashtags.label("hashtags"),
]
if sort_by == SORT_LIKE:
cols.append(_ssul_like_subq().label("sort_value"))
elif sort_by == SORT_COMMENT:
cols.append(_ssul_comment_subq().label("sort_value"))
return select(*cols).where(*where)
def _p2v_f1_branch(where: list, sort_by: str = SORT_CREATED) -> Select:
"""무빙 포스터(F1) 브랜치.
통합 목록의 축과 P2V 필드 대응: store_name←행사명, region←장소,
hashtags←키워드. P2V 에는 좋아요·댓글 축이 없으므로 해당 정렬에서는
sort_value 0 으로 뒤에 깔린다 (UNION 컬럼 수 맞춤용).
"""
cols = [
literal(CT_P2V_VIDEO).label("ctype"),
P2vF1Job.id.label("cid"),
func.coalesce(P2vF1Job.event_name, P2vF1Job.name).label("store_name"),
P2vF1Job.place.label("region"),
P2vF1Job.p2v_video_url.label("movie_url"),
P2vF1Job.created_at.label("created_at"),
literal("").label("task_id"),
P2vF1Job.poster_url.label("poster_url"),
P2vF1Job.name.label("title"),
null().label("description"),
P2vF1Job.keywords.label("hashtags"),
]
if sort_by in (SORT_LIKE, SORT_COMMENT):
cols.append(literal(0).label("sort_value"))
return select(*cols).where(*where)
def _p2v_f2_branch(where: list, sort_by: str = SORT_CREATED) -> Select:
"""포스터 스타일링(F2) 브랜치 — 결과물이 영상이 아니라 이미지다.
movie_url 자리에 결과 이미지 URL 을 싣는다. **프론트는 ctype 으로 분기해
로 그려야 한다** (ssul/video 처럼