diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index f7c6f1e..458d8c9 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import and_, func, or_, select +from sqlalchemy import and_, false, func, or_, select from sqlalchemy.orm import Session, aliased from app.auth import current_user @@ -50,6 +50,7 @@ def get_feed( min_duration: int | None = None, max_duration: int | None = None, max_age_days: int | None = None, + show_normal: bool = True, include_shorts: bool = False, include_live: bool = False, show: str = "unwatched", # all | unwatched | watched | saved | hidden @@ -92,13 +93,21 @@ def get_feed( ) ) - # In the explicit Watched/Saved/Hidden views, show the complete set regardless of - # the Shorts / live default-hiding (so e.g. a hidden Short still shows up there). + # Content-type filter: Normal (regular videos incl. past-stream VODs), Shorts and + # Live/Upcoming are independent toggles; the feed is the union of the enabled types. + # The explicit Watched/Saved/Hidden views show every type so nothing goes missing. explicit_view = show in ("watched", "saved", "hidden") - if not include_shorts and not explicit_view: - query = query.where(Video.is_short.is_(False)) - if not include_live and not explicit_view: - query = query.where(Video.live_status.notin_(HIDDEN_LIVE)) + if not explicit_view: + type_clauses = [] + if show_normal: + type_clauses.append( + and_(Video.is_short.is_(False), Video.live_status.notin_(HIDDEN_LIVE)) + ) + if include_shorts: + type_clauses.append(Video.is_short.is_(True)) + if include_live: + type_clauses.append(Video.live_status.in_(HIDDEN_LIVE)) + query = query.where(or_(*type_clauses) if type_clauses else false()) if channel_id: query = query.where(Video.channel_id == channel_id) if min_duration is not None: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4c375dd..ce58cda 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ const DEFAULT_FILTERS: FeedFilters = { tagMode: "or", q: "", sort: "newest", + includeNormal: true, includeShorts: false, includeLive: false, show: "unwatched", @@ -96,7 +97,7 @@ export default function App() {
- +
diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index ef6ae68..a4270cb 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -1,19 +1,37 @@ import { useEffect, useRef, useState } from "react"; -import { useInfiniteQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { api, type FeedFilters, type Video } from "../lib/api"; import { toast } from "../lib/toast"; import VideoCard from "./VideoCard"; const PAGE = 60; +function matchesView(status: string, show: string): boolean { + switch (show) { + case "hidden": + return status === "hidden"; + case "watched": + return status === "watched"; + case "saved": + return status === "saved"; + case "unwatched": + return status !== "watched" && status !== "hidden"; + default: + return status !== "hidden"; // all + } +} + export default function Feed({ filters, + setFilters, view, }: { filters: FeedFilters; + setFilters: (f: FeedFilters) => void; view: "grid" | "list"; }) { const [overrides, setOverrides] = useState>({}); + const qc = useQueryClient(); const query = useInfiniteQuery({ queryKey: ["feed", filters], @@ -43,20 +61,24 @@ export default function Feed({ function onState(id: string, status: string) { setOverrides((o) => ({ ...o, [id]: status })); - api.setState(id, status).catch(() => {}); + // Refetch once the server has the change so other views (e.g. Hidden) are in sync. + api + .setState(id, status) + .then(() => qc.invalidateQueries({ queryKey: ["feed"] })) + .catch(() => {}); if (status === "hidden") { toast("Video hidden", { label: "Undo", onClick: () => onState(id, "new") }); } } + function onChannelFilter(channelId: string, channelName: string) { + setFilters({ ...filters, channelId, channelName }); + } + const items: Video[] = (query.data?.pages ?? []) .flatMap((p) => p.items) .map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) - .filter((v) => { - const ov = overrides[v.id]; - // In the Hidden view, drop just-unhidden items; elsewhere drop just-hidden ones. - return filters.show === "hidden" ? ov !== "new" : ov !== "hidden"; - }); + .filter((v) => matchesView(v.status, filters.show)); if (query.isLoading) return
Loading feed…
; if (query.isError) return
Couldn't load the feed.
; @@ -66,15 +88,27 @@ export default function Feed({ return (
{view === "grid" ? ( -
+
{items.map((v) => ( - + ))}
) : (
{items.map((v) => ( - + ))}
)} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 5ec01e8..2be3530 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,4 +1,5 @@ import { useQuery } from "@tanstack/react-query"; +import { X } from "lucide-react"; import { api, type FeedFilters, type Tag } from "../lib/api"; const SORTS = [ @@ -64,13 +65,27 @@ export default function Sidebar({ const active = filters.tags.length > 0 || + !filters.includeNormal || filters.includeShorts || filters.includeLive || filters.show !== "unwatched" || - filters.sort !== "newest"; + filters.sort !== "newest" || + !!filters.channelId; return (
); } @@ -93,10 +108,12 @@ export default function VideoCard({ video, view, onState, + onChannelFilter, }: { video: Video; view: "grid" | "list"; onState: (id: string, status: string) => void; + onChannelFilter?: (channelId: string, channelName: string) => void; }) { const watched = video.status === "watched"; const meta = ( @@ -138,7 +155,7 @@ export default function VideoCard({
{meta}
- + ); } @@ -146,12 +163,12 @@ export default function VideoCard({ return (
-
+
{video.channel_thumbnail && (
{meta}
- +
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f6628c2..5707360 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -45,10 +45,12 @@ export interface FeedFilters { tagMode: "or" | "and"; q: string; sort: string; + includeNormal: boolean; includeShorts: boolean; includeLive: boolean; show: string; channelId?: string; + channelName?: string; maxAgeDays?: number; minDuration?: number; maxDuration?: number; @@ -78,6 +80,7 @@ function feedQuery(f: FeedFilters, offset: number, limit: number): string { p.set("tag_mode", f.tagMode); if (f.q) p.set("q", f.q); p.set("sort", f.sort); + p.set("show_normal", String(f.includeNormal)); p.set("include_shorts", String(f.includeShorts)); p.set("include_live", String(f.includeLive)); p.set("show", f.show);