diff --git a/.dockerignore b/.dockerignore index 2c2a02a..0a334d6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,4 +6,5 @@ node_modules .env*.local !.env.example data +!data/criterion-catalog.json docs diff --git a/.gitignore b/.gitignore index eb35a65..f118cca 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ next-env.d.ts # local data data/ +!data/criterion-catalog.json /*.csv # runtime-cached streaming service logos (downloaded from TMDB at runtime) diff --git a/data/criterion-catalog.json b/data/criterion-catalog.json new file mode 100644 index 0000000..902b4f3 --- /dev/null +++ b/data/criterion-catalog.json @@ -0,0 +1,6222 @@ +[ + { + "title": ". . . And the Pursuit of Happiness", + "year": 1986 + }, + { + "title": "12 Angry Men", + "year": 1957 + }, + { + "title": "1984", + "year": 1984 + }, + { + "title": "2 or 3 Things I Know About Her", + "year": 1967 + }, + { + "title": "2046", + "year": 2004 + }, + { + "title": "24 Frames", + "year": 2017 + }, + { + "title": "3 Women", + "year": 1977 + }, + { + "title": "3:10 to Yuma", + "year": 1957 + }, + { + "title": "4 Months, 3 Weeks and 2 Days", + "year": 2007 + }, + { + "title": "45 Years", + "year": 2015 + }, + { + "title": "49th Parallel", + "year": 1941 + }, + { + "title": "71 Fragments of a Chronology of Chance", + "year": 1994 + }, + { + "title": "8½", + "year": 1963 + }, + { + "title": "A Brief History of Time", + "year": 1991 + }, + { + "title": "A Brighter Summer Day", + "year": 1991 + }, + { + "title": "A Canterbury Tale", + "year": 1944 + }, + { + "title": "A Christmas Tale", + "year": 2008 + }, + { + "title": "A Colt Is My Passport", + "year": 1967 + }, + { + "title": "A Constant Forge", + "year": 2000 + }, + { + "title": "A Day in the Country", + "year": 1936 + }, + { + "title": "A Dry White Season", + "year": 1989 + }, + { + "title": "A Face in the Crowd", + "year": 1957 + }, + { + "title": "A Generation", + "year": 1955 + }, + { + "title": "A Hard Day’s Night", + "year": 1964 + }, + { + "title": "A Lesson in Love", + "year": 1954 + }, + { + "title": "A Man Escaped", + "year": 1956 + }, + { + "title": "A Married Couple", + "year": 1969 + }, + { + "title": "A Master Builder", + "year": 2014 + }, + { + "title": "A Matter of Life and Death", + "year": 1946 + }, + { + "title": "A Night to Remember", + "year": 1958 + }, + { + "title": "A Poem Is a Naked Person", + "year": 1974 + }, + { + "title": "A Raisin in the Sun", + "year": 1961 + }, + { + "title": "A Report on the Party and Guests", + "year": 1966 + }, + { + "title": "A River Called Titas", + "year": 1973 + }, + { + "title": "A Room with a View", + "year": 1986 + }, + { + "title": "A Safe Place", + "year": 1971 + }, + { + "title": "A Ship to India", + "year": 1947 + }, + { + "title": "A Special Day", + "year": 1977 + }, + { + "title": "A Story from Chikamatsu", + "year": 1954 + }, + { + "title": "A Story of Floating Weeds", + "year": 1934 + }, + { + "title": "A Tale of Autumn", + "year": 1998 + }, + { + "title": "A Tale of Springtime", + "year": 1990 + }, + { + "title": "A Tale of Summer", + "year": 1996 + }, + { + "title": "A Tale of Winter", + "year": 1992 + }, + { + "title": "A Taste of Honey", + "year": 1961 + }, + { + "title": "A Touch of Zen", + "year": 1971 + }, + { + "title": "A Woman Is a Woman", + "year": 1961 + }, + { + "title": "A Woman Under the Influence", + "year": 1974 + }, + { + "title": "A Woman’s Face", + "year": 1938 + }, + { + "title": "About Dry Grasses", + "year": 2023 + }, + { + "title": "Accattone", + "year": 1961 + }, + { + "title": "Ace in the Hole", + "year": 1951 + }, + { + "title": "Adoption", + "year": 1975 + }, + { + "title": "Adventures of Zatoichi", + "year": 1964 + }, + { + "title": "Affirmations", + "year": 1990 + }, + { + "title": "Afire", + "year": 2023 + }, + { + "title": "After Hours", + "year": 1985 + }, + { + "title": "After Life", + "year": 1998 + }, + { + "title": "After the Curfew", + "year": 1954 + }, + { + "title": "After the Rehearsal", + "year": 1984 + }, + { + "title": "Agnès de ci de là Varda", + "year": 2011 + }, + { + "title": "Akira Kurosawa’s Dreams", + "year": 1990 + }, + { + "title": "Alex Wheatle", + "year": 2020 + }, + { + "title": "Alexander Nevsky", + "year": 1938 + }, + { + "title": "Ali: Fear Eats the Soul", + "year": 1974 + }, + { + "title": "Alice in the Cities", + "year": 1974 + }, + { + "title": "All About Eve", + "year": 1950 + }, + { + "title": "All About My Mother", + "year": 1999 + }, + { + "title": "All Monsters Attack", + "year": 1969 + }, + { + "title": "All Night Long", + "year": 1962 + }, + { + "title": "All of Us Strangers", + "year": 2023 + }, + { + "title": "All That Breathes", + "year": 2022 + }, + { + "title": "All That Heaven Allows", + "year": 1955 + }, + { + "title": "All That Jazz", + "year": 1979 + }, + { + "title": "All That Money Can Buy (a.k.a. The Devil and Daniel Webster)", + "year": 1941 + }, + { + "title": "All the Beauty and the Bloodshed", + "year": 2022 + }, + { + "title": "All These Women", + "year": 1964 + }, + { + "title": "Alphaville", + "year": 1965 + }, + { + "title": "Amarcord", + "year": 1973 + }, + { + "title": "Amores perros", + "year": 2000 + }, + { + "title": "An Actor’s Revenge", + "year": 1963 + }, + { + "title": "An Angel at My Table", + "year": 1990 + }, + { + "title": "An Autumn Afternoon", + "year": 1962 + }, + { + "title": "An Enemy of the People", + "year": 1989 + }, + { + "title": "An Unmarried Woman", + "year": 1978 + }, + { + "title": "Anatomy of a Fall", + "year": 2023 + }, + { + "title": "Anatomy of a Murder", + "year": 1959 + }, + { + "title": "And Everything Is Going Fine", + "year": 2010 + }, + { + "title": "And God Created Woman", + "year": 1956 + }, + { + "title": "And Life Goes On", + "year": 1992 + }, + { + "title": "And the Ship Sails On", + "year": 1983 + }, + { + "title": "Andrei Rublev", + "year": 1966 + }, + { + "title": "Androcles and the Lion", + "year": 1952 + }, + { + "title": "Anselm", + "year": 2023 + }, + { + "title": "Anthem", + "year": 1991 + }, + { + "title": "Antichrist", + "year": 2009 + }, + { + "title": "Antonio Gaudí", + "year": 1984 + }, + { + "title": "Aparajito", + "year": 1956 + }, + { + "title": "Apart from You", + "year": 1933 + }, + { + "title": "Apur Sansar", + "year": 1959 + }, + { + "title": "Arabian Nights", + "year": 1974 + }, + { + "title": "Ariel", + "year": 1988 + }, + { + "title": "Armageddon", + "year": 1998 + }, + { + "title": "Army", + "year": 1944 + }, + { + "title": "Army of Shadows", + "year": 1969 + }, + { + "title": "Arsenic and Old Lace", + "year": 1944 + }, + { + "title": "As Long as You’ve Got Your Health", + "year": 1966 + }, + { + "title": "As Tears Go By", + "year": 1988 + }, + { + "title": "Ashes and Diamonds", + "year": 1958 + }, + { + "title": "Au hasard Balthazar", + "year": 1966 + }, + { + "title": "Au revoir les enfants", + "year": 1987 + }, + { + "title": "Autumn Sonata", + "year": 1978 + }, + { + "title": "Baal", + "year": 1970 + }, + { + "title": "Babette’s Feast", + "year": 1987 + }, + { + "title": "Babo 73", + "year": 1964 + }, + { + "title": "Bad Timing", + "year": 1980 + }, + { + "title": "Badlands", + "year": 1973 + }, + { + "title": "Ballad of a Soldier", + "year": 1959 + }, + { + "title": "Bamboozled", + "year": 2000 + }, + { + "title": "Band of Outsiders", + "year": 1964 + }, + { + "title": "Barcelona", + "year": 1994 + }, + { + "title": "Barry Lyndon", + "year": 1975 + }, + { + "title": "Baxter, Vera Baxter", + "year": 1977 + }, + { + "title": "Bay of Angels", + "year": 1963 + }, + { + "title": "Beastie Boys Video Anthology", + "year": 2000 + }, + { + "title": "Beasts of No Nation", + "year": 2015 + }, + { + "title": "Beau travail", + "year": 1999 + }, + { + "title": "Beauty and the Beast", + "year": 1946 + }, + { + "title": "Bed and Board", + "year": 1970 + }, + { + "title": "Before Midnight", + "year": 2013 + }, + { + "title": "Before Sunrise", + "year": 1995 + }, + { + "title": "Before Sunset", + "year": 2004 + }, + { + "title": "Before the Rain", + "year": 1994 + }, + { + "title": "Being John Malkovich", + "year": 1999 + }, + { + "title": "Being There", + "year": 1979 + }, + { + "title": "Belle de jour", + "year": 1967 + }, + { + "title": "Benny’s Video", + "year": 1992 + }, + { + "title": "Bergman Island", + "year": 2006 + }, + { + "title": "Berlin Alexanderplatz", + "year": 1980 + }, + { + "title": "Betty Blue", + "year": 1986 + }, + { + "title": "Beware of a Holy Whore", + "year": 1970 + }, + { + "title": "Beyond the Hills", + "year": 2012 + }, + { + "title": "Beyond the Law", + "year": 1968 + }, + { + "title": "Beyond the Valley of the Dolls", + "year": 1970 + }, + { + "title": "Bicycle Thieves", + "year": 1948 + }, + { + "title": "Big Deal on Madonna Street", + "year": 1958 + }, + { + "title": "Bigger Than Life", + "year": 1956 + }, + { + "title": "Billy Liar", + "year": 1963 + }, + { + "title": "Bim, the Little Donkey", + "year": 1951 + }, + { + "title": "Bitter Rice", + "year": 1949 + }, + { + "title": "Black Girl", + "year": 1966 + }, + { + "title": "Black God, White Devil", + "year": 1964 + }, + { + "title": "Black Is . . . Black Ain’t", + "year": 1995 + }, + { + "title": "Black Moon", + "year": 1975 + }, + { + "title": "Black Narcissus", + "year": 1947 + }, + { + "title": "Black Orpheus", + "year": 1959 + }, + { + "title": "Black Panthers", + "year": 1970 + }, + { + "title": "Black River", + "year": 1956 + }, + { + "title": "Black Sun", + "year": 1964 + }, + { + "title": "Blaise Pascal", + "year": 1972 + }, + { + "title": "Blast of Silence", + "year": 1961 + }, + { + "title": "Blind Chance", + "year": 1981 + }, + { + "title": "Blithe Spirit", + "year": 1945 + }, + { + "title": "Blonde Venus", + "year": 1932 + }, + { + "title": "Blood for Dracula", + "year": 1974 + }, + { + "title": "Blood Simple", + "year": 1984 + }, + { + "title": "Blood Wedding", + "year": 1981 + }, + { + "title": "Blow Out", + "year": 1981 + }, + { + "title": "Blow-Up", + "year": 1966 + }, + { + "title": "Blue Is the Warmest Color", + "year": 2013 + }, + { + "title": "Blue Velvet", + "year": 1986 + }, + { + "title": "Boat People", + "year": 1982 + }, + { + "title": "Bob le flambeur", + "year": 1956 + }, + { + "title": "Body and Soul", + "year": 1925 + }, + { + "title": "Border Radio", + "year": 1987 + }, + { + "title": "Borderline", + "year": 1930 + }, + { + "title": "Bottle Rocket", + "year": 1996 + }, + { + "title": "Boudu Saved from Drowning", + "year": 1932 + }, + { + "title": "Bound", + "year": 1996 + }, + { + "title": "Bowling for Columbine", + "year": 2002 + }, + { + "title": "Boyhood", + "year": 2014 + }, + { + "title": "Brand upon the Brain!", + "year": 2006 + }, + { + "title": "Branded to Kill", + "year": 1967 + }, + { + "title": "Brazil", + "year": 1985 + }, + { + "title": "Breaker Morant", + "year": 1980 + }, + { + "title": "Breaking the Waves", + "year": 1996 + }, + { + "title": "Breathless", + "year": 1960 + }, + { + "title": "Brief Encounter", + "year": 1945 + }, + { + "title": "Brief Encounters", + "year": 1967 + }, + { + "title": "Bringing Up Baby", + "year": 1938 + }, + { + "title": "Brink of Life", + "year": 1958 + }, + { + "title": "Broadcast News", + "year": 1987 + }, + { + "title": "Brute Force", + "year": 1947 + }, + { + "title": "Buchanan Rides Alone", + "year": 1958 + }, + { + "title": "Buck and the Preacher", + "year": 1972 + }, + { + "title": "Buena Vista Social Club", + "year": 1999 + }, + { + "title": "Bull Durham", + "year": 1988 + }, + { + "title": "Burden of Dreams", + "year": 1982 + }, + { + "title": "Burroughs: The Movie", + "year": 1983 + }, + { + "title": "Caesar and Cleopatra", + "year": 1945 + }, + { + "title": "Calcutta", + "year": 1969 + }, + { + "title": "Cameraperson", + "year": 2016 + }, + { + "title": "Canoa: A Shameful Memory", + "year": 1976 + }, + { + "title": "Capricious Summer", + "year": 1968 + }, + { + "title": "Carl Th. Dreyer—My Metier", + "year": 1995 + }, + { + "title": "Carlos", + "year": 2010 + }, + { + "title": "Carmen", + "year": 1983 + }, + { + "title": "Carnival of Souls", + "year": 1962 + }, + { + "title": "Cartesius", + "year": 1974 + }, + { + "title": "Casque d’or", + "year": 1952 + }, + { + "title": "Cat People", + "year": 1942 + }, + { + "title": "Ceddo", + "year": 1977 + }, + { + "title": "Certain Women", + "year": 2016 + }, + { + "title": "Certified Copy", + "year": 2010 + }, + { + "title": "Chafed Elbows", + "year": 1966 + }, + { + "title": "Chains", + "year": 1949 + }, + { + "title": "Chan Is Missing", + "year": 1982 + }, + { + "title": "Charade", + "year": 1963 + }, + { + "title": "Charulata", + "year": 1964 + }, + { + "title": "Chasing Amy", + "year": 1997 + }, + { + "title": "Che", + "year": 2008 + }, + { + "title": "Chess of the Wind", + "year": 1976 + }, + { + "title": "Children of Paradise", + "year": 1945 + }, + { + "title": "Chilly Scenes of Winter", + "year": 1979 + }, + { + "title": "Chimes at Midnight", + "year": 1966 + }, + { + "title": "Chop Shop", + "year": 2007 + }, + { + "title": "Christ Stopped at Eboli", + "year": 1979 + }, + { + "title": "Chronicle of a Summer", + "year": 1961 + }, + { + "title": "Chungking Express", + "year": 1994 + }, + { + "title": "Circus Angel", + "year": 1965 + }, + { + "title": "Citizen Kane", + "year": 1941 + }, + { + "title": "City Lights", + "year": 1931 + }, + { + "title": "Claire’s Knee", + "year": 1970 + }, + { + "title": "Classe tous risques", + "year": 1960 + }, + { + "title": "Claudine", + "year": 1974 + }, + { + "title": "Clean, Shaven", + "year": 1994 + }, + { + "title": "Close-up", + "year": 1990 + }, + { + "title": "Closely Watched Trains", + "year": 1966 + }, + { + "title": "Clouds of Sils Maria", + "year": 2014 + }, + { + "title": "Cluny Brown", + "year": 1946 + }, + { + "title": "Cléo from 5 to 7", + "year": 1962 + }, + { + "title": "Code Unknown", + "year": 2000 + }, + { + "title": "Cold War", + "year": 2018 + }, + { + "title": "Cold Water", + "year": 1994 + }, + { + "title": "Color Adjustment", + "year": 1992 + }, + { + "title": "Colossal Youth", + "year": 2006 + }, + { + "title": "Comanche Station", + "year": 1960 + }, + { + "title": "Come and See", + "year": 1985 + }, + { + "title": "Come On Children", + "year": 1972 + }, + { + "title": "Contempt", + "year": 1963 + }, + { + "title": "Cooley High", + "year": 1975 + }, + { + "title": "Corridors of Blood", + "year": 1959 + }, + { + "title": "Coup de grâce", + "year": 1976 + }, + { + "title": "Coup de torchon", + "year": 1981 + }, + { + "title": "Crash", + "year": 1996 + }, + { + "title": "Crazed Fruit", + "year": 1956 + }, + { + "title": "Cries and Whispers", + "year": 1972 + }, + { + "title": "Crisis", + "year": 1946 + }, + { + "title": "Cronos", + "year": 1993 + }, + { + "title": "Crossing Delancey", + "year": 1988 + }, + { + "title": "Cruel Gun Story", + "year": 1964 + }, + { + "title": "Crumb", + "year": 1995 + }, + { + "title": "Cría cuervos . . .", + "year": 1976 + }, + { + "title": "Cul-de-sac", + "year": 1966 + }, + { + "title": "Cure", + "year": 1997 + }, + { + "title": "Céline and Julie Go Boating", + "year": 1974 + }, + { + "title": "César", + "year": 1936 + }, + { + "title": "Daddy Longlegs", + "year": 2009 + }, + { + "title": "Daguerréotypes", + "year": 1975 + }, + { + "title": "Daisies", + "year": 1966 + }, + { + "title": "Dance, Girl, Dance", + "year": 1940 + }, + { + "title": "Danton", + "year": 1983 + }, + { + "title": "David Golder", + "year": 1930 + }, + { + "title": "David Lynch: The Art Life", + "year": 2016 + }, + { + "title": "Day for Night", + "year": 1973 + }, + { + "title": "Day of Wrath", + "year": 1943 + }, + { + "title": "Days of Being Wild", + "year": 1990 + }, + { + "title": "Days of Heaven", + "year": 1978 + }, + { + "title": "Dazed and Confused", + "year": 1993 + }, + { + "title": "Dead Man", + "year": 1995 + }, + { + "title": "Dead Ringers", + "year": 1988 + }, + { + "title": "Death by Hanging", + "year": 1968 + }, + { + "title": "Death in Venice", + "year": 1971 + }, + { + "title": "Death of a Cyclist", + "year": 1955 + }, + { + "title": "Decision at Sundown", + "year": 1957 + }, + { + "title": "Deep Cover", + "year": 1992 + }, + { + "title": "Defending Your Life", + "year": 1991 + }, + { + "title": "Dekalog", + "year": 1988 + }, + { + "title": "Demon Pond", + "year": 1979 + }, + { + "title": "Desert Hearts", + "year": 1985 + }, + { + "title": "Design for Living", + "year": 1933 + }, + { + "title": "Destroy All Monsters", + "year": 1968 + }, + { + "title": "Destry Rides Again", + "year": 1939 + }, + { + "title": "Detour", + "year": 1945 + }, + { + "title": "Devi", + "year": 1960 + }, + { + "title": "Devil in a Blue Dress", + "year": 1995 + }, + { + "title": "Dheepan", + "year": 2015 + }, + { + "title": "Diabolique", + "year": 1955 + }, + { + "title": "Diamonds of the Night", + "year": 1964 + }, + { + "title": "Diary of a Chambermaid", + "year": 1964 + }, + { + "title": "Diary of a Country Priest", + "year": 1951 + }, + { + "title": "Dick Johnson Is Dead", + "year": 2020 + }, + { + "title": "Dillinger Is Dead", + "year": 1969 + }, + { + "title": "Dim Sum: A Little Bit of Heart", + "year": 1985 + }, + { + "title": "Dishonored", + "year": 1931 + }, + { + "title": "Divorce Italian Style", + "year": 1961 + }, + { + "title": "Do the Right Thing", + "year": 1989 + }, + { + "title": "Documenteur", + "year": 1981 + }, + { + "title": "Dodes’ka-den", + "year": 1970 + }, + { + "title": "Dogfight", + "year": 1991 + }, + { + "title": "Dollar", + "year": 1938 + }, + { + "title": "Donkey Skin", + "year": 1970 + }, + { + "title": "Dont Look Back", + "year": 1967 + }, + { + "title": "Don’t Look Now", + "year": 1973 + }, + { + "title": "Don’t Play Us Cheap", + "year": 1972 + }, + { + "title": "Dos monjes", + "year": 1934 + }, + { + "title": "Double Indemnity", + "year": 1944 + }, + { + "title": "Double Suicide", + "year": 1969 + }, + { + "title": "Douce", + "year": 1943 + }, + { + "title": "Down by Law", + "year": 1986 + }, + { + "title": "Downhill Racer", + "year": 1969 + }, + { + "title": "Downpour", + "year": 1972 + }, + { + "title": "Dr. Strangelove, or: How I Learned to Stop Worrying and Love the Bomb", + "year": 1964 + }, + { + "title": "Dragnet Girl", + "year": 1933 + }, + { + "title": "Dragon Inn", + "year": 1967 + }, + { + "title": "Dreams", + "year": 1955 + }, + { + "title": "Dressed to Kill", + "year": 1980 + }, + { + "title": "Drive My Car", + "year": 2021 + }, + { + "title": "Drive, He Said", + "year": 1970 + }, + { + "title": "Drugstore Cowboy", + "year": 1989 + }, + { + "title": "Drunken Angel", + "year": 1948 + }, + { + "title": "Dry Summer", + "year": 1964 + }, + { + "title": "Drylongso", + "year": 1998 + }, + { + "title": "Dying at Grace", + "year": 2003 + }, + { + "title": "Désiré", + "year": 1937 + }, + { + "title": "Early Spring", + "year": 1956 + }, + { + "title": "Early Summer", + "year": 1951 + }, + { + "title": "Eastern Condors", + "year": 1987 + }, + { + "title": "Easy Rider", + "year": 1969 + }, + { + "title": "Eating Raoul", + "year": 1982 + }, + { + "title": "Ebirah, Horror of the Deep", + "year": 1966 + }, + { + "title": "Education", + "year": 2020 + }, + { + "title": "Eight Hours Don’t Make a Day", + "year": 1972 + }, + { + "title": "El amor brujo", + "year": 1986 + }, + { + "title": "El Norte", + "year": 1983 + }, + { + "title": "El Sur", + "year": 1983 + }, + { + "title": "Election", + "year": 1999 + }, + { + "title": "Elena and Her Men", + "year": 1956 + }, + { + "title": "Elephant Boy", + "year": 1937 + }, + { + "title": "Elevator to the Gallows", + "year": 1958 + }, + { + "title": "Elvira Madigan", + "year": 1967 + }, + { + "title": "Emitaï", + "year": 1971 + }, + { + "title": "Empire of Passion", + "year": 1978 + }, + { + "title": "Enter the Dragon", + "year": 1973 + }, + { + "title": "EO", + "year": 2022 + }, + { + "title": "Epidemic", + "year": 1987 + }, + { + "title": "Equinox", + "year": 1970 + }, + { + "title": "Equinox Flower", + "year": 1958 + }, + { + "title": "Eraserhead", + "year": 1977 + }, + { + "title": "Ethnic Notions", + "year": 1986 + }, + { + "title": "Europa", + "year": 1991 + }, + { + "title": "Europa Europa", + "year": 1990 + }, + { + "title": "Europe ’51", + "year": 1952 + }, + { + "title": "Everlasting Moments", + "year": 2008 + }, + { + "title": "Every Man for Himself", + "year": 1980 + }, + { + "title": "Every-Night Dreams", + "year": 1933 + }, + { + "title": "Eve’s Bayou", + "year": 1997 + }, + { + "title": "Evil Does Not Exist", + "year": 2023 + }, + { + "title": "Executioners", + "year": 1993 + }, + { + "title": "Exotica", + "year": 1994 + }, + { + "title": "Eyes Without a Face", + "year": 1960 + }, + { + "title": "Eyimofe (This Is My Desire)", + "year": 2020 + }, + { + "title": "F for Fake", + "year": 1975 + }, + { + "title": "Faces", + "year": 1968 + }, + { + "title": "Faces Places", + "year": 2017 + }, + { + "title": "Fail Safe", + "year": 1964 + }, + { + "title": "Fallen Angels", + "year": 1995 + }, + { + "title": "Fanfan la Tulipe", + "year": 1952 + }, + { + "title": "Fanny", + "year": 1932 + }, + { + "title": "Fanny and Alexander: Television Version", + "year": 1983 + }, + { + "title": "Fanny and Alexander: Theatrical Version", + "year": 1982 + }, + { + "title": "Fantastic Mr. Fox", + "year": 2009 + }, + { + "title": "Fantastic Planet", + "year": 1973 + }, + { + "title": "Farewell Amor", + "year": 2020 + }, + { + "title": "Farewell My Concubine", + "year": 1993 + }, + { + "title": "Fast Times at Ridgemont High", + "year": 1982 + }, + { + "title": "Fat Girl", + "year": 2001 + }, + { + "title": "Faya dayi", + "year": 2021 + }, + { + "title": "Fear and Loathing in Las Vegas", + "year": 1998 + }, + { + "title": "Fearless Hyena II", + "year": 1983 + }, + { + "title": "Fellini Satyricon", + "year": 1969 + }, + { + "title": "Female Trouble", + "year": 1974 + }, + { + "title": "Festival", + "year": 1967 + }, + { + "title": "Fiend Without a Face", + "year": 1958 + }, + { + "title": "Fight, Zatoichi, Fight", + "year": 1964 + }, + { + "title": "Fighting Elegy", + "year": 1966 + }, + { + "title": "Fires on the Plain", + "year": 1959 + }, + { + "title": "First Man into Space", + "year": 1959 + }, + { + "title": "Fish Tank", + "year": 2009 + }, + { + "title": "Fishing with John", + "year": 1992 + }, + { + "title": "Fist of Fury", + "year": 1972 + }, + { + "title": "Fists in the Pocket", + "year": 1965 + }, + { + "title": "Five Easy Pieces", + "year": 1970 + }, + { + "title": "Flesh for Frankenstein", + "year": 1973 + }, + { + "title": "Floating Weeds", + "year": 1959 + }, + { + "title": "Flowers of Shanghai", + "year": 1998 + }, + { + "title": "Flunky, Work Hard", + "year": 1931 + }, + { + "title": "Following", + "year": 1999 + }, + { + "title": "For All Mankind", + "year": 1989 + }, + { + "title": "Forbidden Games", + "year": 1952 + }, + { + "title": "Foreign Correspondent", + "year": 1940 + }, + { + "title": "Forty Guns", + "year": 1957 + }, + { + "title": "Fox and His Friends", + "year": 1975 + }, + { + "title": "Frances Ha", + "year": 2013 + }, + { + "title": "Freaks", + "year": 1932 + }, + { + "title": "French Cancan", + "year": 1955 + }, + { + "title": "From the Life of the Marionettes", + "year": 1980 + }, + { + "title": "Frownland", + "year": 2007 + }, + { + "title": "Funny Games", + "year": 1997 + }, + { + "title": "Funny Girl", + "year": 1968 + }, + { + "title": "Fårö Document", + "year": 1970 + }, + { + "title": "Fårö Document 1979", + "year": 1979 + }, + { + "title": "Game of Death", + "year": 1978 + }, + { + "title": "Gate of Flesh", + "year": 1964 + }, + { + "title": "Gate of Hell", + "year": 1953 + }, + { + "title": "Gates of Heaven", + "year": 1978 + }, + { + "title": "General Idi Amin Dada: A Self-Portrait", + "year": 1974 + }, + { + "title": "Genocide", + "year": 1968 + }, + { + "title": "George Washington", + "year": 2000 + }, + { + "title": "Germany Year Zero", + "year": 1948 + }, + { + "title": "Gertrud", + "year": 1964 + }, + { + "title": "Gervaise", + "year": 1956 + }, + { + "title": "Ghidorah, the Three-Headed Monster", + "year": 1964 + }, + { + "title": "Ghost Dog: The Way of the Samurai", + "year": 1999 + }, + { + "title": "Ghost World", + "year": 2001 + }, + { + "title": "Gilda", + "year": 1946 + }, + { + "title": "Gimme Shelter", + "year": 1970 + }, + { + "title": "Girlfight", + "year": 2000 + }, + { + "title": "Girlfriends", + "year": 1978 + }, + { + "title": "Godland", + "year": 2022 + }, + { + "title": "Gods of the Plague", + "year": 1969 + }, + { + "title": "Godzilla", + "year": 1954 + }, + { + "title": "Godzilla Raids Again", + "year": 1955 + }, + { + "title": "Godzilla vs. Gigan", + "year": 1972 + }, + { + "title": "Godzilla vs. Hedorah", + "year": 1971 + }, + { + "title": "Godzilla vs. Mechagodzilla", + "year": 1974 + }, + { + "title": "Godzilla vs. Megalon", + "year": 1973 + }, + { + "title": "God’s Country", + "year": 1985 + }, + { + "title": "Goke, Body Snatcher from Hell", + "year": 1968 + }, + { + "title": "Gomorrah", + "year": 2008 + }, + { + "title": "Good Morning", + "year": 1959 + }, + { + "title": "Graduation", + "year": 2016 + }, + { + "title": "Grand Illusion", + "year": 1937 + }, + { + "title": "Gray’s Anatomy", + "year": 1997 + }, + { + "title": "Great Expectations", + "year": 1946 + }, + { + "title": "Green for Danger", + "year": 1946 + }, + { + "title": "Grey Gardens", + "year": 1976 + }, + { + "title": "Guillermo del Toro’s Pinocchio", + "year": 2022 + }, + { + "title": "Gummo", + "year": 1997 + }, + { + "title": "Half a Loaf of Kung Fu", + "year": 1978 + }, + { + "title": "Hamlet", + "year": 1948 + }, + { + "title": "Hands over the City", + "year": 1963 + }, + { + "title": "Happiness", + "year": 1998 + }, + { + "title": "Happy Together", + "year": 1997 + }, + { + "title": "Harakiri", + "year": 1962 + }, + { + "title": "Hard Boiled", + "year": 1992 + }, + { + "title": "Harlan County USA", + "year": 1976 + }, + { + "title": "Harold and Maude", + "year": 1971 + }, + { + "title": "Head", + "year": 1968 + }, + { + "title": "Heart of a Dog", + "year": 2015 + }, + { + "title": "Hearts and Minds", + "year": 1974 + }, + { + "title": "Heaven Can Wait", + "year": 1943 + }, + { + "title": "Heaven’s Gate", + "year": 1980 + }, + { + "title": "Hedwig and the Angry Inch", + "year": 2001 + }, + { + "title": "Henry V", + "year": 1944 + }, + { + "title": "Here Comes Mr. Jordan", + "year": 1941 + }, + { + "title": "Here Is Your Life", + "year": 1966 + }, + { + "title": "High and Low", + "year": 1963 + }, + { + "title": "High Sierra", + "year": 1941 + }, + { + "title": "Hiroshima mon amour", + "year": 1959 + }, + { + "title": "His Girl Friday", + "year": 1940 + }, + { + "title": "History Is Made at Night", + "year": 1937 + }, + { + "title": "Hobson’s Choice", + "year": 1954 + }, + { + "title": "Holiday", + "year": 1938 + }, + { + "title": "Hollywood Shuffle", + "year": 1987 + }, + { + "title": "Homicide", + "year": 1991 + }, + { + "title": "Hoop Dreams", + "year": 1994 + }, + { + "title": "Hopscotch", + "year": 1980 + }, + { + "title": "Hotel Monterey", + "year": 1972 + }, + { + "title": "Hour of the Wolf", + "year": 1968 + }, + { + "title": "House", + "year": 1977 + }, + { + "title": "House of Games", + "year": 1987 + }, + { + "title": "How to Get Ahead in Advertising", + "year": 1988 + }, + { + "title": "Howards End", + "year": 1992 + }, + { + "title": "Humain, trop humain", + "year": 1973 + }, + { + "title": "Hunger", + "year": 2008 + }, + { + "title": "Husbands", + "year": 1970 + }, + { + "title": "Häxan", + "year": 1922 + }, + { + "title": "Hôtel du Nord", + "year": 1938 + }, + { + "title": "I Am Cuba", + "year": 1964 + }, + { + "title": "I Am Curious—Blue", + "year": 1967 + }, + { + "title": "I Am Curious—Yellow", + "year": 1967 + }, + { + "title": "I Am Waiting", + "year": 1957 + }, + { + "title": "I fidanzati", + "year": 1962 + }, + { + "title": "I Hate But Love", + "year": 1962 + }, + { + "title": "I Knew Her Well", + "year": 1965 + }, + { + "title": "I Know Where I’m Going!", + "year": 1945 + }, + { + "title": "I Live in Fear", + "year": 1955 + }, + { + "title": "I Married a Witch", + "year": 1942 + }, + { + "title": "I Shot Jesse James", + "year": 1949 + }, + { + "title": "I vitelloni", + "year": 1953 + }, + { + "title": "I Walked with a Zombie", + "year": 1943 + }, + { + "title": "I Wanna Hold Your Hand", + "year": 1978 + }, + { + "title": "I Was Born, But . . .", + "year": 1932 + }, + { + "title": "I Will Buy You", + "year": 1956 + }, + { + "title": "I, Daniel Blake", + "year": 2016 + }, + { + "title": "Identification of a Woman", + "year": 1982 + }, + { + "title": "If....", + "year": 1969 + }, + { + "title": "Ikiru", + "year": 1952 + }, + { + "title": "Il bidone", + "year": 1955 + }, + { + "title": "Il Generale Della Rovere", + "year": 1959 + }, + { + "title": "Il posto", + "year": 1961 + }, + { + "title": "Il sorpasso", + "year": 1962 + }, + { + "title": "Imitation of Life", + "year": 1934 + }, + { + "title": "In a Lonely Place", + "year": 1950 + }, + { + "title": "In Cold Blood", + "year": 1967 + }, + { + "title": "In the Heat of the Night", + "year": 1967 + }, + { + "title": "In the Mood for Love", + "year": 2000 + }, + { + "title": "In the Realm of the Senses", + "year": 1976 + }, + { + "title": "In Vanda’s Room", + "year": 2000 + }, + { + "title": "In Which We Serve", + "year": 1942 + }, + { + "title": "India Song", + "year": 1975 + }, + { + "title": "Indiscretion of an American Wife", + "year": 1953 + }, + { + "title": "Infernal Affairs", + "year": 2002 + }, + { + "title": "Infernal Affairs II", + "year": 2003 + }, + { + "title": "Infernal Affairs III", + "year": 2003 + }, + { + "title": "Ingrid Bergman: In Her Own Words", + "year": 2015 + }, + { + "title": "Inland Empire", + "year": 2006 + }, + { + "title": "Innocence Unprotected", + "year": 1968 + }, + { + "title": "Insiang", + "year": 1976 + }, + { + "title": "Inside Llewyn Davis", + "year": 2013 + }, + { + "title": "Insignificance", + "year": 1985 + }, + { + "title": "Insomnia", + "year": 1997 + }, + { + "title": "Intentions of Murder", + "year": 1964 + }, + { + "title": "Intermezzo", + "year": 1936 + }, + { + "title": "Intervista", + "year": 1987 + }, + { + "title": "Intimidation", + "year": 1960 + }, + { + "title": "Invasion of Astro-Monster", + "year": 1965 + }, + { + "title": "Invention for Destruction", + "year": 1958 + }, + { + "title": "Investigation of a Citizen Above Suspicion", + "year": 1970 + }, + { + "title": "Irma Vep", + "year": 1996 + }, + { + "title": "Island of Lost Souls", + "year": 1932 + }, + { + "title": "It Happened One Night", + "year": 1934 + }, + { + "title": "It’s a Mad, Mad, Mad, Mad World", + "year": 1963 + }, + { + "title": "Ivan the Terrible, Part I", + "year": 1944 + }, + { + "title": "Ivan the Terrible, Part II", + "year": 1958 + }, + { + "title": "Ivan’s Childhood", + "year": 1962 + }, + { + "title": "Jabberwocky", + "year": 1977 + }, + { + "title": "Jacquot de Nantes", + "year": 1991 + }, + { + "title": "Jane B. par Agnès V.", + "year": 1988 + }, + { + "title": "Japanese Girls at the Harbor", + "year": 1933 + }, + { + "title": "Japanese Summer: Double Suicide", + "year": 1967 + }, + { + "title": "Japón", + "year": 2002 + }, + { + "title": "Je tu il elle", + "year": 1975 + }, + { + "title": "Jeanne Dielman, 23, quai du Commerce, 1080 Bruxelles", + "year": 1975 + }, + { + "title": "Jellyfish Eyes", + "year": 2013 + }, + { + "title": "Jericho", + "year": 1937 + }, + { + "title": "Jigoku", + "year": 1960 + }, + { + "title": "Jimi Plays Monterey & Shake! Otis at Monterey", + "year": 1986 + }, + { + "title": "Jo Jo Dancer, Your Life Is Calling", + "year": 1986 + }, + { + "title": "Jour de fête", + "year": 1949 + }, + { + "title": "Journey to Italy", + "year": 1954 + }, + { + "title": "Journey to the Beginning of Time", + "year": 1955 + }, + { + "title": "Jubal", + "year": 1956 + }, + { + "title": "Jubilation Street", + "year": 1944 + }, + { + "title": "Jubilee", + "year": 1978 + }, + { + "title": "Judex", + "year": 1963 + }, + { + "title": "Jules and Jim", + "year": 1962 + }, + { + "title": "Juliet of the Spirits", + "year": 1965 + }, + { + "title": "June Night", + "year": 1940 + }, + { + "title": "Jungle Book", + "year": 1942 + }, + { + "title": "Kagemusha", + "year": 1980 + }, + { + "title": "Kalpana", + "year": 1948 + }, + { + "title": "Kameradschaft", + "year": 1931 + }, + { + "title": "Kanal", + "year": 1957 + }, + { + "title": "Kapò", + "year": 1959 + }, + { + "title": "Katzelmacher", + "year": 1969 + }, + { + "title": "Kes", + "year": 1970 + }, + { + "title": "Kicking and Screaming", + "year": 1995 + }, + { + "title": "Kill!", + "year": 1968 + }, + { + "title": "Kind Hearts and Coronets", + "year": 1949 + }, + { + "title": "King Kong vs. Godzilla", + "year": 1963 + }, + { + "title": "King Lear", + "year": 1987 + }, + { + "title": "King of Jazz", + "year": 1930 + }, + { + "title": "King of the Hill", + "year": 1993 + }, + { + "title": "Kings of the Road", + "year": 1976 + }, + { + "title": "Kiss Me Deadly", + "year": 1955 + }, + { + "title": "Klute", + "year": 1971 + }, + { + "title": "Knife in the Water", + "year": 1962 + }, + { + "title": "Koko: A Talking Gorilla", + "year": 1978 + }, + { + "title": "Koyaanisqatsi", + "year": 1983 + }, + { + "title": "Kung-Fu Master!", + "year": 1988 + }, + { + "title": "Kuroneko", + "year": 1968 + }, + { + "title": "Kwaidan", + "year": 1965 + }, + { + "title": "La Bamba", + "year": 1987 + }, + { + "title": "La bête humaine", + "year": 1938 + }, + { + "title": "La Cage aux Folles", + "year": 1978 + }, + { + "title": "La chambre", + "year": 1972 + }, + { + "title": "La chienne", + "year": 1931 + }, + { + "title": "La Ciénaga", + "year": 2001 + }, + { + "title": "La collectionneuse", + "year": 1967 + }, + { + "title": "La commare secca", + "year": 1962 + }, + { + "title": "La cérémonie", + "year": 1995 + }, + { + "title": "La dolce vita", + "year": 1960 + }, + { + "title": "La haine", + "year": 1995 + }, + { + "title": "La Jetée", + "year": 1963 + }, + { + "title": "La Llorona", + "year": 2019 + }, + { + "title": "La notte", + "year": 1961 + }, + { + "title": "La piscine", + "year": 1969 + }, + { + "title": "La Pointe Courte", + "year": 1955 + }, + { + "title": "La poison", + "year": 1951 + }, + { + "title": "La promesse", + "year": 1996 + }, + { + "title": "La ronde", + "year": 1950 + }, + { + "title": "La strada", + "year": 1954 + }, + { + "title": "La tête d’un homme", + "year": 1933 + }, + { + "title": "La vie de bohème", + "year": 1992 + }, + { + "title": "La vie de Jésus", + "year": 1997 + }, + { + "title": "La vérité", + "year": 1960 + }, + { + "title": "Lacombe, Lucien", + "year": 1974 + }, + { + "title": "Lady Snowblood", + "year": 1973 + }, + { + "title": "Lady Snowblood: Love Song of Vengeance", + "year": 1974 + }, + { + "title": "Land of Milk and Honey", + "year": 1971 + }, + { + "title": "Last Holiday", + "year": 1950 + }, + { + "title": "Last Hurrah for Chivalry", + "year": 1979 + }, + { + "title": "Last Summer", + "year": 2023 + }, + { + "title": "Last Year at Marienbad", + "year": 1961 + }, + { + "title": "Late Autumn", + "year": 1960 + }, + { + "title": "Late Spring", + "year": 1949 + }, + { + "title": "Law of the Border", + "year": 1966 + }, + { + "title": "Le 15/8", + "year": 1973 + }, + { + "title": "Le amiche", + "year": 1955 + }, + { + "title": "Le beau Serge", + "year": 1958 + }, + { + "title": "Le bonheur", + "year": 1965 + }, + { + "title": "Le cercle rouge", + "year": 1970 + }, + { + "title": "Le ciel est à vous", + "year": 1944 + }, + { + "title": "Le Corbeau", + "year": 1943 + }, + { + "title": "Le deuxième souffle", + "year": 1966 + }, + { + "title": "Le doulos", + "year": 1962 + }, + { + "title": "Le grand amour", + "year": 1969 + }, + { + "title": "Le Havre", + "year": 2011 + }, + { + "title": "Le jour se lève", + "year": 1939 + }, + { + "title": "Le mariage de Chiffon", + "year": 1942 + }, + { + "title": "Le million", + "year": 1931 + }, + { + "title": "Le notti bianche", + "year": 1957 + }, + { + "title": "Le petit soldat", + "year": 1963 + }, + { + "title": "Le plaisir", + "year": 1952 + }, + { + "title": "Le samouraï", + "year": 1967 + }, + { + "title": "Le silence de la mer", + "year": 1949 + }, + { + "title": "Le trou", + "year": 1960 + }, + { + "title": "Leave Her to Heaven", + "year": 1945 + }, + { + "title": "Leningrad Cowboys Go America", + "year": 1989 + }, + { + "title": "Leningrad Cowboys Meet Moses", + "year": 1994 + }, + { + "title": "Les Blank: Always for Pleasure", + "year": 1968 + }, + { + "title": "Les cousins", + "year": 1959 + }, + { + "title": "Les créatures", + "year": 1966 + }, + { + "title": "Les dames du Bois de Boulogne", + "year": 1945 + }, + { + "title": "Les enfants terribles", + "year": 1950 + }, + { + "title": "Les misérables", + "year": 1934 + }, + { + "title": "Les rendez-vous d’Anna", + "year": 1978 + }, + { + "title": "Les visiteurs du soir", + "year": 1942 + }, + { + "title": "Let the Sunshine In", + "year": 2017 + }, + { + "title": "Letter Never Sent", + "year": 1959 + }, + { + "title": "Lettres d’amour", + "year": 1942 + }, + { + "title": "Life During Wartime", + "year": 2010 + }, + { + "title": "Life Is Sweet", + "year": 1990 + }, + { + "title": "Like Someone in Love", + "year": 2012 + }, + { + "title": "Limelight", + "year": 1952 + }, + { + "title": "Limite", + "year": 1931 + }, + { + "title": "Lions Love (. . . and Lies)", + "year": 1969 + }, + { + "title": "Local Hero", + "year": 1983 + }, + { + "title": "Lola", + "year": 1981 + }, + { + "title": "Lola Montès", + "year": 1955 + }, + { + "title": "Lone Star", + "year": 1996 + }, + { + "title": "Lone Wolf and Cub: Baby Cart at the River Styx", + "year": 1972 + }, + { + "title": "Lone Wolf and Cub: Baby Cart in Peril", + "year": 1972 + }, + { + "title": "Lone Wolf and Cub: Baby Cart in the Land of Demons", + "year": 1973 + }, + { + "title": "Lone Wolf and Cub: Baby Cart to Hades", + "year": 1972 + }, + { + "title": "Lone Wolf and Cub: Sword of Vengeance", + "year": 1972 + }, + { + "title": "Lone Wolf and Cub: White Heaven in Hell", + "year": 1974 + }, + { + "title": "Lonesome", + "year": 1928 + }, + { + "title": "Lord of the Flies", + "year": 1963 + }, + { + "title": "Lost Highway", + "year": 1997 + }, + { + "title": "Lost in America", + "year": 1985 + }, + { + "title": "Louie Bluie", + "year": 1985 + }, + { + "title": "Love & Basketball", + "year": 2000 + }, + { + "title": "Love Affair", + "year": 1939 + }, + { + "title": "Love Affair, or the Case of the Missing Switchboard Operator", + "year": 1967 + }, + { + "title": "Love in the Afternoon", + "year": 1972 + }, + { + "title": "Love Is Colder Than Death", + "year": 1969 + }, + { + "title": "love jones", + "year": 1997 + }, + { + "title": "Love Meetings", + "year": 1964 + }, + { + "title": "Love on the Run", + "year": 1979 + }, + { + "title": "Love Streams", + "year": 1984 + }, + { + "title": "Lovers Rock", + "year": 2020 + }, + { + "title": "Loves of a Blonde", + "year": 1965 + }, + { + "title": "Loving Couples", + "year": 1964 + }, + { + "title": "Lucía", + "year": 1968 + }, + { + "title": "Lumière d’été", + "year": 1943 + }, + { + "title": "Lynch/Oz", + "year": 2022 + }, + { + "title": "Léon Morin, Priest", + "year": 1961 + }, + { + "title": "L’argent", + "year": 1983 + }, + { + "title": "L’Atalante", + "year": 1934 + }, + { + "title": "L’avventura", + "year": 1960 + }, + { + "title": "L’eclisse", + "year": 1962 + }, + { + "title": "L’enfance nue", + "year": 1968 + }, + { + "title": "L’enfant aimé ou Je joue à être une femme mariée", + "year": 1971 + }, + { + "title": "L’humanité", + "year": 1999 + }, + { + "title": "M", + "year": 1931 + }, + { + "title": "Macbeth", + "year": 1971 + }, + { + "title": "Madadayo", + "year": 1993 + }, + { + "title": "Made in U.S.A", + "year": 1966 + }, + { + "title": "Madonna of the Seven Moons", + "year": 1945 + }, + { + "title": "Mafioso", + "year": 1962 + }, + { + "title": "Magnificent Obsession", + "year": 1954 + }, + { + "title": "Maidstone", + "year": 1970 + }, + { + "title": "Major Barbara", + "year": 1941 + }, + { + "title": "Make Way for Tomorrow", + "year": 1937 + }, + { + "title": "Mala Noche", + "year": 1985 + }, + { + "title": "Malcolm X", + "year": 1992 + }, + { + "title": "Mamma Roma", + "year": 1962 + }, + { + "title": "Man Bites Dog", + "year": 1992 + }, + { + "title": "Man Is Not a Bird", + "year": 1965 + }, + { + "title": "Man Push Cart", + "year": 2005 + }, + { + "title": "Mandabi", + "year": 1968 + }, + { + "title": "Mangrove", + "year": 2020 + }, + { + "title": "Manila in the Claws of Light", + "year": 1975 + }, + { + "title": "Marius", + "year": 1931 + }, + { + "title": "Marketa Lazarová", + "year": 1967 + }, + { + "title": "Marriage Story", + "year": 2019 + }, + { + "title": "Martha Graham: Dance on Film", + "year": 1959 + }, + { + "title": "Masculin féminin", + "year": 1966 + }, + { + "title": "Master of the House", + "year": 1925 + }, + { + "title": "Matewan", + "year": 1987 + }, + { + "title": "Mayerling", + "year": 1936 + }, + { + "title": "Maîtresse", + "year": 1976 + }, + { + "title": "McCabe & Mrs. Miller", + "year": 1971 + }, + { + "title": "Me and You and Everyone We Know", + "year": 2005 + }, + { + "title": "Mean Streets", + "year": 1973 + }, + { + "title": "Meantime", + "year": 1984 + }, + { + "title": "Medea", + "year": 1969 + }, + { + "title": "Medicine for Melancholy", + "year": 2008 + }, + { + "title": "Medium Cool", + "year": 1969 + }, + { + "title": "Memories of Murder", + "year": 2003 + }, + { + "title": "Memories of Underdevelopment", + "year": 1968 + }, + { + "title": "Memory for Max, Claire, Ida and Company", + "year": 2005 + }, + { + "title": "Menace II Society", + "year": 1993 + }, + { + "title": "Merrily We Go to Hell", + "year": 1932 + }, + { + "title": "Merry Christmas Mr. Lawrence", + "year": 1983 + }, + { + "title": "Metropolitan", + "year": 1990 + }, + { + "title": "Midnight Cowboy", + "year": 1969 + }, + { + "title": "Mikey and Nicky", + "year": 1976 + }, + { + "title": "Mildred Pierce", + "year": 1945 + }, + { + "title": "Miller’s Crossing", + "year": 1990 + }, + { + "title": "Minding the Gap", + "year": 2018 + }, + { + "title": "Ministry of Fear", + "year": 1944 + }, + { + "title": "Miracle in Milan", + "year": 1951 + }, + { + "title": "Mirror", + "year": 1975 + }, + { + "title": "Mishima: A Life in Four Chapters", + "year": 1985 + }, + { + "title": "Miss Julie", + "year": 1951 + }, + { + "title": "Missing", + "year": 1982 + }, + { + "title": "Mississippi Masala", + "year": 1991 + }, + { + "title": "Mister Johnson", + "year": 1990 + }, + { + "title": "Modern Times", + "year": 1936 + }, + { + "title": "Mon oncle", + "year": 1958 + }, + { + "title": "Mon oncle Antoine", + "year": 1971 + }, + { + "title": "Mona Lisa", + "year": 1986 + }, + { + "title": "Monsieur Hulot’s Holiday", + "year": 1953 + }, + { + "title": "Monsieur Verdoux", + "year": 1947 + }, + { + "title": "Monsoon Wedding", + "year": 2001 + }, + { + "title": "Monte Carlo", + "year": 1930 + }, + { + "title": "Monterey Pop", + "year": 1968 + }, + { + "title": "Monty Python’s Life of Brian", + "year": 1979 + }, + { + "title": "Moonage Daydream", + "year": 2022 + }, + { + "title": "Moonrise", + "year": 1948 + }, + { + "title": "Moonrise Kingdom", + "year": 2012 + }, + { + "title": "Moonstruck", + "year": 1987 + }, + { + "title": "Morning for the Osone Family", + "year": 1946 + }, + { + "title": "Morocco", + "year": 1930 + }, + { + "title": "Mother", + "year": 1996 + }, + { + "title": "Mothra vs. Godzilla", + "year": 1964 + }, + { + "title": "Mouchette", + "year": 1967 + }, + { + "title": "Mr. Freedom", + "year": 1969 + }, + { + "title": "Mr. Klein", + "year": 1976 + }, + { + "title": "Mr. Thank You", + "year": 1936 + }, + { + "title": "Mudbound", + "year": 2017 + }, + { + "title": "Mulholland Dr.", + "year": 2001 + }, + { + "title": "Multiple Maniacs", + "year": 1970 + }, + { + "title": "Muna moto", + "year": 1975 + }, + { + "title": "Mur Murs", + "year": 1981 + }, + { + "title": "Muriel, or The Time of Return", + "year": 1963 + }, + { + "title": "Murmur of the Heart", + "year": 1971 + }, + { + "title": "My Beautiful Laundrette", + "year": 1985 + }, + { + "title": "My Brilliant Career", + "year": 1979 + }, + { + "title": "My Crasy Life", + "year": 1992 + }, + { + "title": "My Darling Clementine", + "year": 1946 + }, + { + "title": "My Dinner with André", + "year": 1981 + }, + { + "title": "My Life as a Dog", + "year": 1985 + }, + { + "title": "My Lucky Stars", + "year": 1985 + }, + { + "title": "My Man Godfrey", + "year": 1936 + }, + { + "title": "My Night at Maud’s", + "year": 1969 + }, + { + "title": "My Own Private Idaho", + "year": 1991 + }, + { + "title": "My Winnipeg", + "year": 2007 + }, + { + "title": "Mysterious Object at Noon", + "year": 2000 + }, + { + "title": "Mystery Train", + "year": 1989 + }, + { + "title": "Naked", + "year": 1993 + }, + { + "title": "Naked Lunch", + "year": 1991 + }, + { + "title": "Nanny", + "year": 2022 + }, + { + "title": "Nanook of the North", + "year": 1922 + }, + { + "title": "Naqoyqatsi", + "year": 2002 + }, + { + "title": "Nashville", + "year": 1975 + }, + { + "title": "Native Land", + "year": 1942 + }, + { + "title": "New Tale of Zatoichi", + "year": 1963 + }, + { + "title": "News from Home", + "year": 1976 + }, + { + "title": "Night and Fog", + "year": 1955 + }, + { + "title": "Night and the City", + "year": 1950 + }, + { + "title": "Night Games", + "year": 1966 + }, + { + "title": "Night of the Living Dead", + "year": 1968 + }, + { + "title": "Night on Earth", + "year": 1991 + }, + { + "title": "Night Train to Munich", + "year": 1940 + }, + { + "title": "Nightmare Alley", + "year": 1947 + }, + { + "title": "Nights of Cabiria", + "year": 1957 + }, + { + "title": "No Bears", + "year": 2022 + }, + { + "title": "No Blood Relation", + "year": 1932 + }, + { + "title": "No Country for Old Men", + "year": 2007 + }, + { + "title": "No More Excuses", + "year": 1968 + }, + { + "title": "No Regrets for Our Youth", + "year": 1946 + }, + { + "title": "Nobody’s Children", + "year": 1952 + }, + { + "title": "Non, Je Ne Regrette Rien (No Regret)", + "year": 1993 + }, + { + "title": "Not a Pretty Picture", + "year": 1975 + }, + { + "title": "Nothing but a Man", + "year": 1964 + }, + { + "title": "Notorious", + "year": 1946 + }, + { + "title": "Now, Voyager", + "year": 1942 + }, + { + "title": "Nowhere", + "year": 1997 + }, + { + "title": "Odd Man Out", + "year": 1947 + }, + { + "title": "Oedipus Rex", + "year": 1967 + }, + { + "title": "Okja", + "year": 2017 + }, + { + "title": "Old Joy", + "year": 2006 + }, + { + "title": "Oliver Twist", + "year": 1948 + }, + { + "title": "On the Waterfront", + "year": 1954 + }, + { + "title": "Once Upon a Time in China", + "year": 1991 + }, + { + "title": "Once Upon a Time in China II", + "year": 1992 + }, + { + "title": "Once Upon a Time in China III", + "year": 1993 + }, + { + "title": "Once Upon a Time in China IV", + "year": 1993 + }, + { + "title": "Once Upon a Time in China V", + "year": 1994 + }, + { + "title": "One False Move", + "year": 1992 + }, + { + "title": "One Hour with You", + "year": 1932 + }, + { + "title": "One Hundred and One Nights", + "year": 1995 + }, + { + "title": "One Night in Miami . . .", + "year": 2020 + }, + { + "title": "One Sings, the Other Doesn’t", + "year": 1977 + }, + { + "title": "One Wonderful Sunday", + "year": 1947 + }, + { + "title": "One-Eyed Jacks", + "year": 1961 + }, + { + "title": "Onibaba", + "year": 1964 + }, + { + "title": "Only Angels Have Wings", + "year": 1939 + }, + { + "title": "Opening Night", + "year": 1977 + }, + { + "title": "Ordet", + "year": 1955 + }, + { + "title": "Original Cast Album: “Company”", + "year": 1970 + }, + { + "title": "Orlando, My Political Biography", + "year": 2023 + }, + { + "title": "Ornamental Hairpin", + "year": 1941 + }, + { + "title": "Orpheus", + "year": 1950 + }, + { + "title": "Osaka Elegy", + "year": 1936 + }, + { + "title": "Ossos", + "year": 1997 + }, + { + "title": "Othello", + "year": 1952 + }, + { + "title": "Overlord", + "year": 1975 + }, + { + "title": "Paddle to the Sea", + "year": 1966 + }, + { + "title": "Paisan", + "year": 1946 + }, + { + "title": "Pale Flower", + "year": 1964 + }, + { + "title": "Pandora’s Box", + "year": 1929 + }, + { + "title": "Panique", + "year": 1946 + }, + { + "title": "Pan’s Labyrinth", + "year": 2006 + }, + { + "title": "Paper Moon", + "year": 1973 + }, + { + "title": "Parade", + "year": 1974 + }, + { + "title": "Parasite", + "year": 2019 + }, + { + "title": "Pariah", + "year": 2011 + }, + { + "title": "Paris Belongs to Us", + "year": 1961 + }, + { + "title": "Paris Is Burning", + "year": 1990 + }, + { + "title": "Paris, Texas", + "year": 1984 + }, + { + "title": "Passing Fancy", + "year": 1933 + }, + { + "title": "Pat Garrett and Billy the Kid", + "year": 1973 + }, + { + "title": "Pather Panchali", + "year": 1955 + }, + { + "title": "Paths of Glory", + "year": 1957 + }, + { + "title": "Patriotism", + "year": 1966 + }, + { + "title": "Paul Robeson: Tribute to an Artist", + "year": 1979 + }, + { + "title": "Pearls of the Deep", + "year": 1966 + }, + { + "title": "Peeping Tom", + "year": 1960 + }, + { + "title": "People on Sunday", + "year": 1930 + }, + { + "title": "Perfect Days", + "year": 2023 + }, + { + "title": "Performance", + "year": 1970 + }, + { + "title": "Persona", + "year": 1966 + }, + { + "title": "Personal Shopper", + "year": 2016 + }, + { + "title": "Petite maman", + "year": 2021 + }, + { + "title": "Phantom India", + "year": 1969 + }, + { + "title": "Phoenix", + "year": 2014 + }, + { + "title": "Pickpocket", + "year": 1959 + }, + { + "title": "Pickup on South Street", + "year": 1953 + }, + { + "title": "Picnic at Hanging Rock", + "year": 1975 + }, + { + "title": "Pierrot le fou", + "year": 1965 + }, + { + "title": "Pigs and Battleships", + "year": 1962 + }, + { + "title": "Pina", + "year": 2011 + }, + { + "title": "Pink Flamingos", + "year": 1972 + }, + { + "title": "Pitfall", + "year": 1962 + }, + { + "title": "Pixote", + "year": 1980 + }, + { + "title": "Place de la République", + "year": 1974 + }, + { + "title": "PlayTime", + "year": 1967 + }, + { + "title": "Pleasures of the Flesh", + "year": 1965 + }, + { + "title": "Poil de carotte", + "year": 1932 + }, + { + "title": "Police Story", + "year": 1985 + }, + { + "title": "Police Story 2", + "year": 1988 + }, + { + "title": "Polyester", + "year": 1981 + }, + { + "title": "Porcile", + "year": 1969 + }, + { + "title": "Port of Call", + "year": 1948 + }, + { + "title": "Port of Flowers", + "year": 1943 + }, + { + "title": "Port of Shadows", + "year": 1938 + }, + { + "title": "Portrait of a Lady on Fire", + "year": 2019 + }, + { + "title": "Poto and Cabengo", + "year": 1980 + }, + { + "title": "Powaqqatsi", + "year": 1988 + }, + { + "title": "Prisioneros de la tierra", + "year": 1939 + }, + { + "title": "Punch-Drunk Love", + "year": 2002 + }, + { + "title": "Purple Noon", + "year": 1960 + }, + { + "title": "Putney Swope", + "year": 1969 + }, + { + "title": "Pygmalion", + "year": 1938 + }, + { + "title": "Pépé le moko", + "year": 1937 + }, + { + "title": "Quadrille", + "year": 1938 + }, + { + "title": "Quadrophenia", + "year": 1979 + }, + { + "title": "Quai des Orfèvres", + "year": 1947 + }, + { + "title": "Querelle", + "year": 1982 + }, + { + "title": "Raging Bull", + "year": 1980 + }, + { + "title": "Ran", + "year": 1985 + }, + { + "title": "Rashomon", + "year": 1950 + }, + { + "title": "Ratcatcher", + "year": 1999 + }, + { + "title": "Raven’s End", + "year": 1963 + }, + { + "title": "Real Life", + "year": 1979 + }, + { + "title": "Rebecca", + "year": 1940 + }, + { + "title": "Red Beard", + "year": 1965 + }, + { + "title": "Red Desert", + "year": 1964 + }, + { + "title": "Red River", + "year": 1948 + }, + { + "title": "Red, White and Blue", + "year": 2020 + }, + { + "title": "Redes", + "year": 1936 + }, + { + "title": "Rembrandt", + "year": 1936 + }, + { + "title": "Remorques", + "year": 1941 + }, + { + "title": "Repo Man", + "year": 1984 + }, + { + "title": "Repulsion", + "year": 1965 + }, + { + "title": "Return of the Prodigal Son", + "year": 1967 + }, + { + "title": "Revanche", + "year": 2008 + }, + { + "title": "Revenge", + "year": 1989 + }, + { + "title": "Richard III", + "year": 1955 + }, + { + "title": "Ride in the Whirlwind", + "year": 1966 + }, + { + "title": "Ride Lonesome", + "year": 1959 + }, + { + "title": "Ride the Pink Horse", + "year": 1947 + }, + { + "title": "Ride with the Devil", + "year": 1999 + }, + { + "title": "Rififi", + "year": 1955 + }, + { + "title": "Riot in Cell Block 11", + "year": 1954 + }, + { + "title": "Risky Business", + "year": 1983 + }, + { + "title": "Robinson Crusoe on Mars", + "year": 1964 + }, + { + "title": "RoboCop", + "year": 1987 + }, + { + "title": "Rolling Thunder Revue: A Bob Dylan Story by Martin Scorsese", + "year": 2019 + }, + { + "title": "Roma", + "year": 1972 + }, + { + "title": "Rome Open City", + "year": 1945 + }, + { + "title": "Romeo and Juliet", + "year": 1968 + }, + { + "title": "Rosemary’s Baby", + "year": 1968 + }, + { + "title": "Rosetta", + "year": 1999 + }, + { + "title": "Rouge", + "year": 1987 + }, + { + "title": "Routine Pleasures", + "year": 1986 + }, + { + "title": "Rumble Fish", + "year": 1983 + }, + { + "title": "Rushmore", + "year": 1998 + }, + { + "title": "Rusty Knife", + "year": 1958 + }, + { + "title": "Ryuichi Sakamoto | Opus", + "year": 2023 + }, + { + "title": "Safe", + "year": 1995 + }, + { + "title": "Safety Last!", + "year": 1923 + }, + { + "title": "Saint Omer", + "year": 2022 + }, + { + "title": "Salesman", + "year": 1969 + }, + { + "title": "Salvatore Giuliano", + "year": 1962 + }, + { + "title": "Salò, or The 120 Days of Sodom", + "year": 1976 + }, + { + "title": "Samaritan Zatoichi", + "year": 1968 + }, + { + "title": "Sambizanga", + "year": 1972 + }, + { + "title": "Samurai I: Musashi Miyamoto", + "year": 1954 + }, + { + "title": "Samurai II: Duel at Ichijoji Temple", + "year": 1955 + }, + { + "title": "Samurai III: Duel at Ganryu Island", + "year": 1956 + }, + { + "title": "Samurai Rebellion", + "year": 1967 + }, + { + "title": "Samurai Spy", + "year": 1965 + }, + { + "title": "Sanders of the River", + "year": 1935 + }, + { + "title": "Sanjuro", + "year": 1962 + }, + { + "title": "Sans Soleil", + "year": 1983 + }, + { + "title": "Sanshiro Sugata", + "year": 1943 + }, + { + "title": "Sanshiro Sugata, Part Two", + "year": 1945 + }, + { + "title": "Sansho the Bailiff", + "year": 1954 + }, + { + "title": "Sapphire", + "year": 1959 + }, + { + "title": "Saraband", + "year": 2003 + }, + { + "title": "Saute ma ville", + "year": 1968 + }, + { + "title": "Sawdust and Tinsel", + "year": 1953 + }, + { + "title": "Scandal", + "year": 1950 + }, + { + "title": "Scanners", + "year": 1981 + }, + { + "title": "Scarface", + "year": 1932 + }, + { + "title": "Scenes from a Marriage", + "year": 1973 + }, + { + "title": "Schizopolis", + "year": 1996 + }, + { + "title": "Seconds", + "year": 1966 + }, + { + "title": "Secret Honor", + "year": 1984 + }, + { + "title": "Secret Sunshine", + "year": 2007 + }, + { + "title": "Secrets & Lies", + "year": 1996 + }, + { + "title": "Seduced and Abandoned", + "year": 1964 + }, + { + "title": "Senso", + "year": 1954 + }, + { + "title": "Seven Samurai", + "year": 1954 + }, + { + "title": "sex, lies, and videotape", + "year": 1989 + }, + { + "title": "Shadows", + "year": 1959 + }, + { + "title": "Shadows in Paradise", + "year": 1986 + }, + { + "title": "Shaft", + "year": 1971 + }, + { + "title": "Shallow Grave", + "year": 1994 + }, + { + "title": "Shame", + "year": 1968 + }, + { + "title": "Shampoo", + "year": 1975 + }, + { + "title": "Shanghai Express", + "year": 1932 + }, + { + "title": "Shoah", + "year": 1985 + }, + { + "title": "Shock Corridor", + "year": 1963 + }, + { + "title": "Shoot the Piano Player", + "year": 1960 + }, + { + "title": "Short Cuts", + "year": 1993 + }, + { + "title": "Show Boat", + "year": 1936 + }, + { + "title": "Sid & Nancy", + "year": 1986 + }, + { + "title": "Simon of the Desert", + "year": 1965 + }, + { + "title": "Sing a Song of Sex", + "year": 1967 + }, + { + "title": "Sisters", + "year": 1973 + }, + { + "title": "Sisters of the Gion", + "year": 1936 + }, + { + "title": "Slacker", + "year": 1991 + }, + { + "title": "Smiles of a Summer Night", + "year": 1955 + }, + { + "title": "Smithereens", + "year": 1982 + }, + { + "title": "Smooth Talk", + "year": 1985 + }, + { + "title": "Solaris", + "year": 1972 + }, + { + "title": "Soleil Ô", + "year": 1970 + }, + { + "title": "Some Like It Hot", + "year": 1959 + }, + { + "title": "Something Wild", + "year": 1986 + }, + { + "title": "Son of Godzilla", + "year": 1967 + }, + { + "title": "Sound of Metal", + "year": 2019 + }, + { + "title": "Spartacus", + "year": 1960 + }, + { + "title": "Speedy", + "year": 1928 + }, + { + "title": "Spellbound", + "year": 1945 + }, + { + "title": "Spiritual Kung Fu", + "year": 1978 + }, + { + "title": "Stagecoach", + "year": 1939 + }, + { + "title": "Stalker", + "year": 1979 + }, + { + "title": "State of Siege", + "year": 1972 + }, + { + "title": "Still Walking", + "year": 2008 + }, + { + "title": "Stolen Kisses", + "year": 1968 + }, + { + "title": "Story of a Prostitute", + "year": 1965 + }, + { + "title": "Stowaway in the Sky", + "year": 1960 + }, + { + "title": "Stranger Than Paradise", + "year": 1984 + }, + { + "title": "Straw Dogs", + "year": 1971 + }, + { + "title": "Stray Dog", + "year": 1949 + }, + { + "title": "Street of Shame", + "year": 1956 + }, + { + "title": "Street Without End", + "year": 1934 + }, + { + "title": "Streetwise", + "year": 1984 + }, + { + "title": "Stromboli", + "year": 1950 + }, + { + "title": "Sullivan’s Travels", + "year": 1941 + }, + { + "title": "Summer Hours", + "year": 2008 + }, + { + "title": "Summer Interlude", + "year": 1951 + }, + { + "title": "Summer with Monika", + "year": 1953 + }, + { + "title": "Summertime", + "year": 1955 + }, + { + "title": "Sunday Bloody Sunday", + "year": 1971 + }, + { + "title": "Sundays and Cybèle", + "year": 1962 + }, + { + "title": "Suzanne’s Career", + "year": 1963 + }, + { + "title": "Sweet Movie", + "year": 1974 + }, + { + "title": "Sweet Smell of Success", + "year": 1957 + }, + { + "title": "Sweet Sweetback’s Baadasssss Song", + "year": 1971 + }, + { + "title": "Sweetie", + "year": 1989 + }, + { + "title": "Swing Time", + "year": 1936 + }, + { + "title": "Sword of the Beast", + "year": 1965 + }, + { + "title": "Sylvie et le fantôme", + "year": 1946 + }, + { + "title": "Sólo con tu pareja", + "year": 1991 + }, + { + "title": "Taipei Story", + "year": 1985 + }, + { + "title": "Take Aim at the Police Van", + "year": 1960 + }, + { + "title": "Take Out", + "year": 2004 + }, + { + "title": "Tampopo", + "year": 1985 + }, + { + "title": "Tanner ’88", + "year": 1988 + }, + { + "title": "Targets", + "year": 1968 + }, + { + "title": "Taste of Cherry", + "year": 1997 + }, + { + "title": "Teorema", + "year": 1968 + }, + { + "title": "Terror of Mechagodzilla", + "year": 1975 + }, + { + "title": "Tess", + "year": 1979 + }, + { + "title": "Testament of Orpheus", + "year": 1959 + }, + { + "title": "That Hamilton Woman", + "year": 1941 + }, + { + "title": "That Night’s Wife", + "year": 1930 + }, + { + "title": "That Obscure Object of Desire", + "year": 1977 + }, + { + "title": "The 39 Steps", + "year": 1935 + }, + { + "title": "The 400 Blows", + "year": 1959 + }, + { + "title": "The Adventures of Baron Munchausen", + "year": 1988 + }, + { + "title": "The Age of Innocence", + "year": 1993 + }, + { + "title": "The Age of the Medici", + "year": 1973 + }, + { + "title": "The American Friend", + "year": 1977 + }, + { + "title": "The American Soldier", + "year": 1970 + }, + { + "title": "The Ascent", + "year": 1977 + }, + { + "title": "The Asphalt Jungle", + "year": 1950 + }, + { + "title": "The Atomic Submarine", + "year": 1959 + }, + { + "title": "The Awful Truth", + "year": 1937 + }, + { + "title": "The Baby Carriage", + "year": 1963 + }, + { + "title": "The Bad Sleep Well", + "year": 1960 + }, + { + "title": "The Bakery Girl of Monceau", + "year": 1963 + }, + { + "title": "The Baker’s Wife", + "year": 1938 + }, + { + "title": "The Ballad of Gregorio Cortez", + "year": 1982 + }, + { + "title": "The Ballad of Narayama", + "year": 1958 + }, + { + "title": "The Bank Dick", + "year": 1940 + }, + { + "title": "The Baron of Arizona", + "year": 1950 + }, + { + "title": "The Battle of Algiers", + "year": 1966 + }, + { + "title": "The Beaches of Agnès", + "year": 2008 + }, + { + "title": "The Beales of Grey Gardens", + "year": 2006 + }, + { + "title": "The Beast", + "year": 2023 + }, + { + "title": "The Big Boss", + "year": 1971 + }, + { + "title": "The Big Chill", + "year": 1983 + }, + { + "title": "The Big City", + "year": 1963 + }, + { + "title": "The Bitter Tears of Petra von Kant", + "year": 1972 + }, + { + "title": "The Black Stallion", + "year": 1979 + }, + { + "title": "The Blob", + "year": 1958 + }, + { + "title": "The Blood of a Poet", + "year": 1930 + }, + { + "title": "The Breakfast Club", + "year": 1985 + }, + { + "title": "The Breaking Point", + "year": 1950 + }, + { + "title": "The Bridge", + "year": 1959 + }, + { + "title": "The Brood", + "year": 1979 + }, + { + "title": "The Browning Version", + "year": 1951 + }, + { + "title": "The Burmese Harp", + "year": 1956 + }, + { + "title": "The Cameraman", + "year": 1928 + }, + { + "title": "The Canterbury Tales", + "year": 1972 + }, + { + "title": "The Celebration", + "year": 1998 + }, + { + "title": "The Children Are Watching Us", + "year": 1944 + }, + { + "title": "The Circus", + "year": 1928 + }, + { + "title": "The Cloud-Capped Star", + "year": 1960 + }, + { + "title": "The Color of Pomegranates", + "year": 1969 + }, + { + "title": "The Comfort of Strangers", + "year": 1990 + }, + { + "title": "The Complete Mr. Arkadin", + "year": 1955 + }, + { + "title": "The Confession", + "year": 1970 + }, + { + "title": "The Count of the Old Town", + "year": 1935 + }, + { + "title": "The Cranes Are Flying", + "year": 1957 + }, + { + "title": "The Cremator", + "year": 1969 + }, + { + "title": "The Curious Case of Benjamin Button", + "year": 2008 + }, + { + "title": "The Damned", + "year": 1969 + }, + { + "title": "The Darjeeling Limited", + "year": 2007 + }, + { + "title": "The Daytrippers", + "year": 1996 + }, + { + "title": "The Decameron", + "year": 1971 + }, + { + "title": "The Devil Is a Woman", + "year": 1935 + }, + { + "title": "The Devil’s Backbone", + "year": 2001 + }, + { + "title": "The Devil’s Eye", + "year": 1960 + }, + { + "title": "The Discreet Charm of the Bourgeoisie", + "year": 1972 + }, + { + "title": "The Docks of New York", + "year": 1928 + }, + { + "title": "The Doom Generation", + "year": 1995 + }, + { + "title": "The Double Life of Véronique", + "year": 1991 + }, + { + "title": "The Drum", + "year": 1938 + }, + { + "title": "The Earrings of Madame de . . .", + "year": 1953 + }, + { + "title": "The Eight Mountains", + "year": 2022 + }, + { + "title": "The Element of Crime", + "year": 1984 + }, + { + "title": "The Elephant Man", + "year": 1980 + }, + { + "title": "The Emigrants", + "year": 1971 + }, + { + "title": "The Emperor Jones", + "year": 1933 + }, + { + "title": "The End of Summer", + "year": 1961 + }, + { + "title": "The Executioner", + "year": 1963 + }, + { + "title": "The Exterminating Angel", + "year": 1962 + }, + { + "title": "The Fabulous Baron Munchausen", + "year": 1962 + }, + { + "title": "The Face of Another", + "year": 1966 + }, + { + "title": "The Fallen Idol", + "year": 1948 + }, + { + "title": "The Fearless Hyena", + "year": 1979 + }, + { + "title": "The Fire Within", + "year": 1963 + }, + { + "title": "The Firemen’s Ball", + "year": 1967 + }, + { + "title": "The Fisher King", + "year": 1991 + }, + { + "title": "The Flavor of Green Tea over Rice", + "year": 1952 + }, + { + "title": "The Flight of the Phoenix", + "year": 1965 + }, + { + "title": "The Flowers of St. Francis", + "year": 1950 + }, + { + "title": "The Forgiveness of Blood", + "year": 2011 + }, + { + "title": "The Four Feathers", + "year": 1939 + }, + { + "title": "The French Lieutenant’s Woman", + "year": 1981 + }, + { + "title": "The Freshman", + "year": 1925 + }, + { + "title": "The Friends of Eddie Coyle", + "year": 1973 + }, + { + "title": "The Fugitive Kind", + "year": 1960 + }, + { + "title": "The Funeral", + "year": 1984 + }, + { + "title": "The Furies", + "year": 1950 + }, + { + "title": "The Game", + "year": 1997 + }, + { + "title": "The Girl Can’t Help It", + "year": 1956 + }, + { + "title": "The Girls", + "year": 1968 + }, + { + "title": "The Gleaners and I", + "year": 2000 + }, + { + "title": "The Gleaners and I: Two Years Later", + "year": 2002 + }, + { + "title": "The Gold Rush", + "year": 1942 + }, + { + "title": "The Golden Coach", + "year": 1953 + }, + { + "title": "The Gospel According to Matthew", + "year": 1964 + }, + { + "title": "The Graduate", + "year": 1967 + }, + { + "title": "The Grand Budapest Hotel", + "year": 2014 + }, + { + "title": "The Great Beauty", + "year": 2013 + }, + { + "title": "The Great Dictator", + "year": 1940 + }, + { + "title": "The Great Escape", + "year": 1963 + }, + { + "title": "The Grifters", + "year": 1990 + }, + { + "title": "The Gunfighter", + "year": 1950 + }, + { + "title": "The Harder They Come", + "year": 1972 + }, + { + "title": "The Haunted Strangler", + "year": 1958 + }, + { + "title": "The Hawks and the Sparrows", + "year": 1966 + }, + { + "title": "The Heiress", + "year": 1949 + }, + { + "title": "The Hero", + "year": 1966 + }, + { + "title": "The Heroic Trio", + "year": 1993 + }, + { + "title": "The Hidden Fortress", + "year": 1958 + }, + { + "title": "The Hit", + "year": 1984 + }, + { + "title": "The Home and the World", + "year": 1984 + }, + { + "title": "The Honeymoon Killers", + "year": 1969 + }, + { + "title": "The Horse’s Mouth", + "year": 1958 + }, + { + "title": "The Housemaid", + "year": 1960 + }, + { + "title": "The Human Condition", + "year": 1959 + }, + { + "title": "The Ice Storm", + "year": 1997 + }, + { + "title": "The Idiot", + "year": 1951 + }, + { + "title": "The Immortal Story", + "year": 1968 + }, + { + "title": "The Importance of Being Earnest", + "year": 1952 + }, + { + "title": "The In-Laws", + "year": 1979 + }, + { + "title": "The Incredible Shrinking Man", + "year": 1957 + }, + { + "title": "The Inheritance", + "year": 1962 + }, + { + "title": "The Inland Sea", + "year": 1991 + }, + { + "title": "The Innocent", + "year": 2022 + }, + { + "title": "The Innocents", + "year": 1961 + }, + { + "title": "The Insect Woman", + "year": 1963 + }, + { + "title": "The Irishman", + "year": 2019 + }, + { + "title": "The Joke", + "year": 1969 + }, + { + "title": "The Kid", + "year": 1921 + }, + { + "title": "The Kid Brother", + "year": 1927 + }, + { + "title": "The Kid with a Bike", + "year": 2011 + }, + { + "title": "The Killer", + "year": 1989 + }, + { + "title": "The Killing", + "year": 1956 + }, + { + "title": "The Killing of a Chinese Bookie", + "year": 1976 + }, + { + "title": "The King of Kings", + "year": 1927 + }, + { + "title": "The King of Marvin Gardens", + "year": 1972 + }, + { + "title": "The Lady Eve", + "year": 1941 + }, + { + "title": "The Lady Vanishes", + "year": 1938 + }, + { + "title": "The Last Command", + "year": 1928 + }, + { + "title": "The Last Days of Disco", + "year": 1998 + }, + { + "title": "The Last Emperor", + "year": 1987 + }, + { + "title": "The Last Metro", + "year": 1980 + }, + { + "title": "The Last Picture Show", + "year": 1971 + }, + { + "title": "The Last Temptation of Christ", + "year": 1988 + }, + { + "title": "The Last Waltz", + "year": 1978 + }, + { + "title": "The Last Wave", + "year": 1977 + }, + { + "title": "The League of Gentlemen", + "year": 1960 + }, + { + "title": "The Learning Tree", + "year": 1969 + }, + { + "title": "The Leopard", + "year": 1963 + }, + { + "title": "The Life and Death of Colonel Blimp", + "year": 1943 + }, + { + "title": "The Life Aquatic with Steve Zissou", + "year": 2004 + }, + { + "title": "The Life of Oharu", + "year": 1952 + }, + { + "title": "The Living Magoroku", + "year": 1943 + }, + { + "title": "The Living Skeleton", + "year": 1968 + }, + { + "title": "The Lodger: A Story of the London Fog", + "year": 1927 + }, + { + "title": "The Long Day Closes", + "year": 1992 + }, + { + "title": "The Long Farewell", + "year": 1971 + }, + { + "title": "The Long Good Friday", + "year": 1980 + }, + { + "title": "The Lost Honor of Katharina Blum", + "year": 1975 + }, + { + "title": "The Love Parade", + "year": 1929 + }, + { + "title": "The Lovers", + "year": 1958 + }, + { + "title": "The Lure", + "year": 2015 + }, + { + "title": "The Magic Flute", + "year": 1975 + }, + { + "title": "The Magician", + "year": 1958 + }, + { + "title": "The Magnificent Ambersons", + "year": 1942 + }, + { + "title": "The Making of Fanny and Alexander", + "year": 1982 + }, + { + "title": "The Makioka Sisters", + "year": 1983 + }, + { + "title": "The Man in Grey", + "year": 1943 + }, + { + "title": "The Man Who Fell to Earth", + "year": 1976 + }, + { + "title": "The Man Who Knew Too Much", + "year": 1934 + }, + { + "title": "The Manchurian Candidate", + "year": 1962 + }, + { + "title": "The Marriage of Maria Braun", + "year": 1979 + }, + { + "title": "The Masseurs and a Woman", + "year": 1938 + }, + { + "title": "The Match Factory Girl", + "year": 1990 + }, + { + "title": "The Men Who Tread on the Tiger’s Tail", + "year": 1945 + }, + { + "title": "The Merchant of Four Seasons", + "year": 1971 + }, + { + "title": "The Mikado", + "year": 1939 + }, + { + "title": "The Milky Way", + "year": 1969 + }, + { + "title": "The Model Couple", + "year": 1977 + }, + { + "title": "The Moment of Truth", + "year": 1965 + }, + { + "title": "The Most Beautiful", + "year": 1944 + }, + { + "title": "The Most Dangerous Game", + "year": 1932 + }, + { + "title": "The Mother and the Whore", + "year": 1973 + }, + { + "title": "The Music Room", + "year": 1958 + }, + { + "title": "The Mystic", + "year": 1925 + }, + { + "title": "The Naked City", + "year": 1948 + }, + { + "title": "The Naked Island", + "year": 1960 + }, + { + "title": "The Naked Kiss", + "year": 1964 + }, + { + "title": "The Naked Prey", + "year": 1965 + }, + { + "title": "The New Land", + "year": 1972 + }, + { + "title": "The New World", + "year": 2005 + }, + { + "title": "The Night of the Hunter", + "year": 1955 + }, + { + "title": "The Night Porter", + "year": 1974 + }, + { + "title": "The Only Son", + "year": 1936 + }, + { + "title": "The Organizer", + "year": 1963 + }, + { + "title": "The Other Side of Hope", + "year": 2017 + }, + { + "title": "The Others", + "year": 2001 + }, + { + "title": "The Palm Beach Story", + "year": 1942 + }, + { + "title": "The Parallax View", + "year": 1974 + }, + { + "title": "The Passion of Anna", + "year": 1969 + }, + { + "title": "The Passion of Joan of Arc", + "year": 1928 + }, + { + "title": "The Pearls of the Crown", + "year": 1937 + }, + { + "title": "The Phantom Carriage", + "year": 1921 + }, + { + "title": "The Phantom of Liberty", + "year": 1974 + }, + { + "title": "The Philadelphia Story", + "year": 1940 + }, + { + "title": "The Piano", + "year": 1993 + }, + { + "title": "The Piano Teacher", + "year": 2001 + }, + { + "title": "The Player", + "year": 1992 + }, + { + "title": "The Pornographers", + "year": 1966 + }, + { + "title": "The Power of the Dog", + "year": 2021 + }, + { + "title": "The Prince of Tides", + "year": 1991 + }, + { + "title": "The Princess Bride", + "year": 1987 + }, + { + "title": "The Private Life of Don Juan", + "year": 1934 + }, + { + "title": "The Private Life of Henry VIII", + "year": 1933 + }, + { + "title": "The Proud Valley", + "year": 1940 + }, + { + "title": "The Red Balloon", + "year": 1956 + }, + { + "title": "The Red Shoes", + "year": 1948 + }, + { + "title": "The Rise of Catherine the Great", + "year": 1934 + }, + { + "title": "The Rite", + "year": 1969 + }, + { + "title": "The River", + "year": 1951 + }, + { + "title": "The Roaring Twenties", + "year": 1939 + }, + { + "title": "The Rock", + "year": 1996 + }, + { + "title": "The Rose", + "year": 1979 + }, + { + "title": "The Royal Tenenbaums", + "year": 2001 + }, + { + "title": "The Rules of the Game", + "year": 1939 + }, + { + "title": "The Ruling Class", + "year": 1972 + }, + { + "title": "The Runner", + "year": 1984 + }, + { + "title": "The Scarlet Empress", + "year": 1934 + }, + { + "title": "The Secret of the Grain", + "year": 2007 + }, + { + "title": "The Serpent’s Egg", + "year": 1977 + }, + { + "title": "The Servant", + "year": 1963 + }, + { + "title": "The Seventh Continent", + "year": 1989 + }, + { + "title": "The Seventh Seal", + "year": 1957 + }, + { + "title": "The Seventh Victim", + "year": 1943 + }, + { + "title": "The Shape of Water", + "year": 2017 + }, + { + "title": "The Shooting", + "year": 1966 + }, + { + "title": "The Shop on Main Street", + "year": 1965 + }, + { + "title": "The Silence", + "year": 1963 + }, + { + "title": "The Silence of the Lambs", + "year": 1991 + }, + { + "title": "The Small Back Room", + "year": 1949 + }, + { + "title": "The Smiling Lieutenant", + "year": 1931 + }, + { + "title": "The Soft Skin", + "year": 1964 + }, + { + "title": "The Spirit of the Beehive", + "year": 1973 + }, + { + "title": "The Spy Who Came in from the Cold", + "year": 1965 + }, + { + "title": "The Squid and the Whale", + "year": 2005 + }, + { + "title": "The Steel Helmet", + "year": 1951 + }, + { + "title": "The Story of a Cheat", + "year": 1936 + }, + { + "title": "The Story of a Three Day Pass", + "year": 1967 + }, + { + "title": "The Story of Temple Drake", + "year": 1933 + }, + { + "title": "The Story of the Last Chrysanthemum", + "year": 1939 + }, + { + "title": "The Stranger", + "year": 1991 + }, + { + "title": "The Suitor", + "year": 1963 + }, + { + "title": "The Sword of Doom", + "year": 1966 + }, + { + "title": "The Taking of Power by Louis XIV", + "year": 1966 + }, + { + "title": "The Tale of Zatoichi", + "year": 1962 + }, + { + "title": "The Tale of Zatoichi Continues", + "year": 1962 + }, + { + "title": "The Tales of Hoffmann", + "year": 1951 + }, + { + "title": "The Tall T", + "year": 1957 + }, + { + "title": "The Testament of Dr. Mabuse", + "year": 1933 + }, + { + "title": "The Thick-Walled Room", + "year": 1956 + }, + { + "title": "The Thief of Bagdad", + "year": 1940 + }, + { + "title": "The Thin Blue Line", + "year": 1988 + }, + { + "title": "The Thin Red Line", + "year": 1998 + }, + { + "title": "The Third Man", + "year": 1949 + }, + { + "title": "The Threepenny Opera", + "year": 1931 + }, + { + "title": "The Times of Harvey Milk", + "year": 1984 + }, + { + "title": "The Tin Drum", + "year": 1979 + }, + { + "title": "The Touch", + "year": 1971 + }, + { + "title": "The Tree of Life", + "year": 2011 + }, + { + "title": "The Tree of Wooden Clogs", + "year": 1978 + }, + { + "title": "The Trial", + "year": 1962 + }, + { + "title": "The Two of Us", + "year": 1967 + }, + { + "title": "The Umbrellas of Cherbourg", + "year": 1964 + }, + { + "title": "The Unbearable Lightness of Being", + "year": 1988 + }, + { + "title": "The Underground Railroad", + "year": 2021 + }, + { + "title": "The Uninvited", + "year": 1944 + }, + { + "title": "The Unknown", + "year": 1927 + }, + { + "title": "The Vanishing", + "year": 1988 + }, + { + "title": "The Velvet Underground", + "year": 2021 + }, + { + "title": "The Virgin Spring", + "year": 1960 + }, + { + "title": "The Virgin Suicides", + "year": 1999 + }, + { + "title": "The Wages of Fear", + "year": 1953 + }, + { + "title": "The War of the Worlds", + "year": 1953 + }, + { + "title": "The War Room", + "year": 1993 + }, + { + "title": "The Warped Ones", + "year": 1960 + }, + { + "title": "The Watermelon Woman", + "year": 1996 + }, + { + "title": "The Way of the Dragon", + "year": 1972 + }, + { + "title": "The White Angel", + "year": 1955 + }, + { + "title": "The White Sheik", + "year": 1952 + }, + { + "title": "The Wicked Lady", + "year": 1945 + }, + { + "title": "The Worst Person in the World", + "year": 2021 + }, + { + "title": "The X from Outer Space", + "year": 1967 + }, + { + "title": "The Young Girls of Rochefort", + "year": 1967 + }, + { + "title": "The Young Master", + "year": 1980 + }, + { + "title": "Thelma & Louise", + "year": 1991 + }, + { + "title": "There Was a Father", + "year": 1942 + }, + { + "title": "They Live by Night", + "year": 1948 + }, + { + "title": "Thief", + "year": 1981 + }, + { + "title": "Thieves’ Highway", + "year": 1949 + }, + { + "title": "Things to Come", + "year": 1936 + }, + { + "title": "Thirst", + "year": 1949 + }, + { + "title": "Thirst for Love", + "year": 1967 + }, + { + "title": "This Happy Breed", + "year": 1944 + }, + { + "title": "This Is Not a Burial, It’s a Resurrection", + "year": 2019 + }, + { + "title": "This Is Spinal Tap", + "year": 1984 + }, + { + "title": "This Sporting Life", + "year": 1963 + }, + { + "title": "Three Colors: Blue", + "year": 1993 + }, + { + "title": "Three Colors: Red", + "year": 1994 + }, + { + "title": "Three Colors: White", + "year": 1994 + }, + { + "title": "Three Documentaries", + "year": 1962 + }, + { + "title": "Three Outlaw Samurai", + "year": 1964 + }, + { + "title": "Three Resurrected Drunkards", + "year": 1968 + }, + { + "title": "Throne of Blood", + "year": 1957 + }, + { + "title": "Through a Glass Darkly", + "year": 1961 + }, + { + "title": "Through the Olive Trees", + "year": 1994 + }, + { + "title": "Throw Down", + "year": 2004 + }, + { + "title": "Tie Me Up! Tie Me Down!", + "year": 1990 + }, + { + "title": "Time", + "year": 2020 + }, + { + "title": "Time Bandits", + "year": 1981 + }, + { + "title": "Tiny Furniture", + "year": 2010 + }, + { + "title": "Tiny: The Life of Erin Blackwell", + "year": 2016 + }, + { + "title": "To Be or Not to Be", + "year": 1942 + }, + { + "title": "To Die For", + "year": 1995 + }, + { + "title": "To Joy", + "year": 1950 + }, + { + "title": "To Sleep with Anger", + "year": 1990 + }, + { + "title": "Tokyo Chorus", + "year": 1931 + }, + { + "title": "Tokyo Drifter", + "year": 1966 + }, + { + "title": "Tokyo Olympiad", + "year": 1965 + }, + { + "title": "Tokyo Story", + "year": 1953 + }, + { + "title": "Tokyo Twilight", + "year": 1957 + }, + { + "title": "Tom Jones", + "year": 1963 + }, + { + "title": "Tongues Untied", + "year": 1989 + }, + { + "title": "Toni", + "year": 1935 + }, + { + "title": "Tootsie", + "year": 1982 + }, + { + "title": "Topsy-Turvy", + "year": 1999 + }, + { + "title": "Tori and Lokita", + "year": 2022 + }, + { + "title": "Torment", + "year": 1944 + }, + { + "title": "Tormento", + "year": 1950 + }, + { + "title": "Total Balalaika Show", + "year": 1994 + }, + { + "title": "Totally F***ed Up", + "year": 1993 + }, + { + "title": "Touchez pas au grisbi", + "year": 1954 + }, + { + "title": "Touki bouki", + "year": 1973 + }, + { + "title": "Tout va bien", + "year": 1972 + }, + { + "title": "Town Bloody Hall", + "year": 1979 + }, + { + "title": "Traffic", + "year": 2000 + }, + { + "title": "Trafic", + "year": 1971 + }, + { + "title": "Trainspotting", + "year": 1996 + }, + { + "title": "Trances", + "year": 1981 + }, + { + "title": "Triangle of Sadness", + "year": 2022 + }, + { + "title": "Trouble in Paradise", + "year": 1932 + }, + { + "title": "True Stories", + "year": 1986 + }, + { + "title": "Tunes of Glory", + "year": 1960 + }, + { + "title": "Twenty-Four Eyes", + "year": 1954 + }, + { + "title": "Twin Peaks: Fire Walk with Me", + "year": 1992 + }, + { + "title": "Two Days, One Night", + "year": 2014 + }, + { + "title": "Two Girls on the Street", + "year": 1939 + }, + { + "title": "Two Tons of Turquoise to Taos Tonight", + "year": 1975 + }, + { + "title": "Two-Lane Blacktop", + "year": 1971 + }, + { + "title": "Tótem", + "year": 2023 + }, + { + "title": "Ugetsu", + "year": 1953 + }, + { + "title": "Umberto D.", + "year": 1952 + }, + { + "title": "Un carnet de bal", + "year": 1937 + }, + { + "title": "Uncle Yanco", + "year": 1968 + }, + { + "title": "Uncut Gems", + "year": 2019 + }, + { + "title": "Under the Roofs of Paris", + "year": 1930 + }, + { + "title": "Under the Volcano", + "year": 1984 + }, + { + "title": "Underworld", + "year": 1927 + }, + { + "title": "Une chambre en ville", + "year": 1982 + }, + { + "title": "Unfaithfully Yours", + "year": 1948 + }, + { + "title": "Until the End of the World", + "year": 1991 + }, + { + "title": "Vagabond", + "year": 1985 + }, + { + "title": "Valerie and Her Week of Wonders", + "year": 1970 + }, + { + "title": "Valley of the Dolls", + "year": 1967 + }, + { + "title": "Vampyr", + "year": 1932 + }, + { + "title": "Vanya on 42nd Street", + "year": 1994 + }, + { + "title": "Varda by Agnès", + "year": 2019 + }, + { + "title": "Variety Lights", + "year": 1950 + }, + { + "title": "Vengeance Is Mine", + "year": 1979 + }, + { + "title": "Vernon, Florida", + "year": 1981 + }, + { + "title": "Veronika Voss", + "year": 1982 + }, + { + "title": "Victim", + "year": 1961 + }, + { + "title": "Victims of Sin", + "year": 1951 + }, + { + "title": "Videodrome", + "year": 1983 + }, + { + "title": "Violence at Noon", + "year": 1966 + }, + { + "title": "Viridiana", + "year": 1961 + }, + { + "title": "Visions of Eight", + "year": 1973 + }, + { + "title": "Vive le Tour", + "year": 1962 + }, + { + "title": "Vivre sa vie", + "year": 1962 + }, + { + "title": "W. C. Fields—Six Short Films", + "year": 1933 + }, + { + "title": "Waiting Women", + "year": 1952 + }, + { + "title": "Walk Cheerfully", + "year": 1930 + }, + { + "title": "Walkabout", + "year": 1971 + }, + { + "title": "Walker", + "year": 1987 + }, + { + "title": "WALL·E", + "year": 2008 + }, + { + "title": "Walpurgis Night", + "year": 1935 + }, + { + "title": "Wanda", + "year": 1970 + }, + { + "title": "War and Peace", + "year": 1966 + }, + { + "title": "Warrendale", + "year": 1967 + }, + { + "title": "Watermelon Man", + "year": 1970 + }, + { + "title": "Watership Down", + "year": 1978 + }, + { + "title": "Weekend", + "year": 2011 + }, + { + "title": "Werckmeister Harmonies", + "year": 2000 + }, + { + "title": "Westfront 1918", + "year": 1930 + }, + { + "title": "When a Woman Ascends the Stairs", + "year": 1960 + }, + { + "title": "When We Were Kings", + "year": 1996 + }, + { + "title": "Where Is the Friend’s House?", + "year": 1987 + }, + { + "title": "White Dog", + "year": 1982 + }, + { + "title": "White Mane", + "year": 1953 + }, + { + "title": "White Material", + "year": 2009 + }, + { + "title": "Who Are You, Polly Maggoo?", + "year": 1966 + }, + { + "title": "Wild 90", + "year": 1968 + }, + { + "title": "Wild Strawberries", + "year": 1957 + }, + { + "title": "Wildlife", + "year": 2018 + }, + { + "title": "Winchester ’73", + "year": 1950 + }, + { + "title": "Wings", + "year": 1966 + }, + { + "title": "Wings of Desire", + "year": 1987 + }, + { + "title": "Winter Light", + "year": 1963 + }, + { + "title": "Wise Blood", + "year": 1979 + }, + { + "title": "Withnail and I", + "year": 1986 + }, + { + "title": "Woman in the Dunes", + "year": 1964 + }, + { + "title": "Woman of the Year", + "year": 1942 + }, + { + "title": "Women in Love", + "year": 1969 + }, + { + "title": "Women of the Night", + "year": 1948 + }, + { + "title": "Women on the Verge of a Nervous Breakdown", + "year": 1988 + }, + { + "title": "Wooden Crosses", + "year": 1932 + }, + { + "title": "Working Girls", + "year": 1986 + }, + { + "title": "World on a Wire", + "year": 1973 + }, + { + "title": "WR: Mysteries of the Organism", + "year": 1971 + }, + { + "title": "Written on the Wind", + "year": 1956 + }, + { + "title": "Wrong Move", + "year": 1975 + }, + { + "title": "Xala", + "year": 1975 + }, + { + "title": "Y tu mamá también", + "year": 2001 + }, + { + "title": "Yi Yi", + "year": 2000 + }, + { + "title": "Yojimbo", + "year": 1961 + }, + { + "title": "Young Mr. Lincoln", + "year": 1939 + }, + { + "title": "Young Törless", + "year": 1966 + }, + { + "title": "Youth of the Beast", + "year": 1963 + }, + { + "title": "Yoyo", + "year": 1965 + }, + { + "title": "Z", + "year": 1969 + }, + { + "title": "Zatoichi and the Chess Expert", + "year": 1965 + }, + { + "title": "Zatoichi and the Chest of Gold", + "year": 1964 + }, + { + "title": "Zatoichi and the Doomed Man", + "year": 1965 + }, + { + "title": "Zatoichi and the Fugitives", + "year": 1968 + }, + { + "title": "Zatoichi at Large", + "year": 1972 + }, + { + "title": "Zatoichi Challenged", + "year": 1967 + }, + { + "title": "Zatoichi Goes to the Fire Festival", + "year": 1970 + }, + { + "title": "Zatoichi in Desperation", + "year": 1972 + }, + { + "title": "Zatoichi Meets the One-Armed Swordsman", + "year": 1971 + }, + { + "title": "Zatoichi Meets Yojimbo", + "year": 1970 + }, + { + "title": "Zatoichi on the Road", + "year": 1963 + }, + { + "title": "Zatoichi the Fugitive", + "year": 1963 + }, + { + "title": "Zatoichi the Outlaw", + "year": 1967 + }, + { + "title": "Zatoichi’s Cane Sword", + "year": 1967 + }, + { + "title": "Zatoichi’s Conspiracy", + "year": 1973 + }, + { + "title": "Zatoichi’s Flashing Sword", + "year": 1964 + }, + { + "title": "Zatoichi’s Pilgrimage", + "year": 1966 + }, + { + "title": "Zatoichi’s Revenge", + "year": 1965 + }, + { + "title": "Zatoichi’s Vengeance", + "year": 1966 + }, + { + "title": "Zazie dans le métro", + "year": 1960 + }, + { + "title": "Zéro de conduite", + "year": 1933 + }, + { + "title": "¡Alambrista!", + "year": 1977 + }, + { + "title": "À nos amours", + "year": 1983 + }, + { + "title": "À nous la liberté", + "year": 1931 + }, + { + "title": "À propos de Nice", + "year": 1930 + }, + { + "title": "Ådalen 31", + "year": 1969 + }, + { + "title": "’Round Midnight", + "year": 1986 + } +] diff --git a/docs/superpowers/plans/2026-07-22-match-night.md b/docs/superpowers/plans/2026-07-22-match-night.md new file mode 100644 index 0000000..e2b7732 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-match-night.md @@ -0,0 +1,1747 @@ +# Match Night Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Tinder-style swipe feature ("Match Night") where Ian and Krista each independently thumbs-up/down a deck of candidate movies (seeded from a bundled Criterion catalog file + TMDB popular), and a mutual thumbs-up auto-adds the film to the watchlist with a match badge. + +**Architecture:** Two new Prisma models (`SwipeCandidate`, `Swipe`) plus a `matchedViaSwipe` flag on `Movie`. A `src/lib/match-night.ts` module owns all business logic (deck refill, next-candidate lookup, swipe/match transaction); two thin API routes expose it; a new `/match-night` page + card component drive the UI; the existing `MovieRow` gets a small badge. + +**Tech Stack:** Next.js API routes, Prisma (SQLite via `better-sqlite3` adapter), Vitest + Testing Library — all matching existing project conventions. + +## Global Constraints + +- Follow the spec at `docs/superpowers/specs/2026-07-22-match-night-design.md` (as revised after review — Criterion sourcing uses a bundled static file, not a live scrape). +- `User` type is `'user1' | 'user2'` (existing `src/types/index.ts`); user display names come from `getUserNames()` / `/api/user-names`, never hardcode "Krista"/"Ian" in code. +- Reuse `USER_KEYS` / `otherUser` from `src/lib/user-utils.ts` for validation — do not redefine. +- No drag/swipe gestures — tap-only 👍/👎 buttons (spec non-goal). +- No toast/notification on match — the only surfacing is the `matchedViaSwipe` badge on the watchlist card (spec decision). +- Refill threshold = 5, batch size = 20 — hardcoded constants for v1, not in Settings (spec decision). +- The read-check-write around a mutual up-vote (and the stale-candidate check) MUST run inside a single `prisma.$transaction(async (tx) => {...})` interactive transaction — this is the concurrency guarantee from the spec, not optional. +- Match every existing file's code style when editing it (e.g. `movie-row.tsx` uses semicolons; most `src/lib`/`src/app/api` files do not — don't reformat surrounding code). + +--- + +### Task 1: Prisma schema — new models, `matchedViaSwipe`, and shared types + +**Files:** +- Modify: `prisma/schema.prisma` +- Modify: `src/types/index.ts` +- Test: manual verification (schema/type tasks aren't unit-testable in isolation; verified via `tsc` and `prisma validate`) + +**Interfaces:** +- Produces: Prisma models `SwipeCandidate` (fields: `id, tmdbId, imdbId, title, year, runtime, description, posterUrl, source, status, createdAt, swipes`) and `Swipe` (fields: `id, candidateId, user, vote, swipedAt, candidate`), each accessible as `prisma.swipeCandidate` / `prisma.swipe`. `Movie.matchedViaSwipe: boolean`. +- Produces TS types in `src/types/index.ts`: `SwipeSource = 'criterion' | 'tmdb'`, `SwipeStatus = 'pending' | 'dead' | 'matched'`, `SwipeVote = 'up' | 'down'`, `SwipeCandidateRecord` interface, `Swipe` interface. `Movie` interface gains `matchedViaSwipe: boolean`. + +- [ ] **Step 1: Add the new models and field to `prisma/schema.prisma`** + +Add `matchedViaSwipe` to the existing `Movie` model (insert after `streamingLink`): + +```prisma +model Movie { + id Int @id @default(autoincrement()) + title String + year Int + runtime Int + description String + posterUrl String + imdbId String @unique + tmdbId Int @unique + criterionUrl String? + imdbUrl String? + sortOrder Int + status String @default("watchlist") + seerrRequestId String? + seerrMediaId String? + seerrStatus String @default("not_requested") + watchedAt DateTime? + createdAt DateTime @default(now()) + streamingLastChecked DateTime? + streamingLink String? + matchedViaSwipe Boolean @default(false) + ratings Rating[] + streamingProviders StreamingProvider[] +} +``` + +Add two new models after `StreamingProvider`: + +```prisma +model SwipeCandidate { + id Int @id @default(autoincrement()) + tmdbId Int @unique + imdbId String + title String + year Int + runtime Int + description String + posterUrl String + source String // "criterion" | "tmdb" + status String @default("pending") // pending | dead | matched + createdAt DateTime @default(now()) + swipes Swipe[] +} + +model Swipe { + id Int @id @default(autoincrement()) + candidateId Int + user String + vote String // "up" | "down" + swipedAt DateTime @default(now()) + candidate SwipeCandidate @relation(fields: [candidateId], references: [id], onDelete: Cascade) + + @@unique([candidateId, user]) +} +``` + +- [ ] **Step 2: Generate and run the migration** + +Run: `npx prisma migrate dev --name add_match_night` +Expected: a new folder under `prisma/migrations/` is created, migration applies cleanly, and `npx prisma generate` runs as part of the command with no errors. + +- [ ] **Step 3: Add TypeScript types to `src/types/index.ts`** + +Add near the existing `Rating`/`RatingValue` types: + +```typescript +export type SwipeSource = 'criterion' | 'tmdb' +export type SwipeStatus = 'pending' | 'dead' | 'matched' +export type SwipeVote = 'up' | 'down' + +export interface SwipeCandidateRecord { + id: number + tmdbId: number + imdbId: string + title: string + year: number + runtime: number + description: string + posterUrl: string + source: SwipeSource + status: SwipeStatus + createdAt: Date | string +} + +export interface Swipe { + id: number + candidateId: number + user: User + vote: SwipeVote + swipedAt: Date | string +} +``` + +Add `matchedViaSwipe: boolean` to the existing `Movie` interface, right after `streamingLink`: + +```typescript + streamingLink?: string | null + matchedViaSwipe: boolean + ratings?: Rating[] +``` + +- [ ] **Step 4: Verify the project still typechecks** + +Run: `npx tsc --noEmit` +Expected: no new errors (existing test mock objects that build a full `Movie` literal, e.g. in `tests/api.movies.test.ts` and `tests/movie-row.test.tsx`, will need `matchedViaSwipe: false` added — do that now if `tsc` flags them). + +- [ ] **Step 5: Commit** + +```bash +git add prisma/schema.prisma prisma/migrations src/types/index.ts tests/api.movies.test.ts tests/movie-row.test.tsx +git commit -m "feat: add SwipeCandidate/Swipe models and matchedViaSwipe field for Match Night" +``` + +--- + +### Task 2: Bundled Criterion catalog data file + +**Files:** +- Create: `data/criterion-catalog.json` +- Test: `tests/criterion-catalog.test.ts` + +**Interfaces:** +- Produces: a JSON file at `data/criterion-catalog.json` containing an array of `{ title: string, year?: number }`, and a loader `getCriterionCatalog(): Array<{ title: string; year?: number }>` in `src/lib/criterion-catalog.ts` that later tasks import. + +- [ ] **Step 1: Write the failing test** + +Create `tests/criterion-catalog.test.ts`: + +```typescript +// tests/criterion-catalog.test.ts +import { describe, it, expect } from 'vitest' +import { getCriterionCatalog } from '@/lib/criterion-catalog' + +describe('getCriterionCatalog', () => { + it('returns a non-empty array of title entries', () => { + const catalog = getCriterionCatalog() + expect(Array.isArray(catalog)).toBe(true) + expect(catalog.length).toBeGreaterThan(10) + }) + + it('every entry has a non-empty string title', () => { + const catalog = getCriterionCatalog() + for (const entry of catalog) { + expect(typeof entry.title).toBe('string') + expect(entry.title.length).toBeGreaterThan(0) + } + }) + + it('every entry with a year has a plausible film year', () => { + const catalog = getCriterionCatalog() + for (const entry of catalog) { + if (entry.year !== undefined) { + expect(entry.year).toBeGreaterThan(1880) + expect(entry.year).toBeLessThanOrEqual(new Date().getFullYear()) + } + } + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/criterion-catalog.test.ts` +Expected: FAIL — `Cannot find module '@/lib/criterion-catalog'` + +- [ ] **Step 3: Create the data file** + +Create `data/criterion-catalog.json`: + +```json +[ + { "title": "Seven Samurai", "year": 1954 }, + { "title": "Rashomon", "year": 1950 }, + { "title": "Ikiru", "year": 1952 }, + { "title": "Yojimbo", "year": 1961 }, + { "title": "Sanjuro", "year": 1962 }, + { "title": "High and Low", "year": 1963 }, + { "title": "The Hidden Fortress", "year": 1958 }, + { "title": "Ran", "year": 1985 }, + { "title": "Kagemusha", "year": 1980 }, + { "title": "The Seventh Seal", "year": 1957 }, + { "title": "Persona", "year": 1966 }, + { "title": "Wild Strawberries", "year": 1957 }, + { "title": "Fanny and Alexander", "year": 1982 }, + { "title": "8 1/2", "year": 1963 }, + { "title": "La Strada", "year": 1954 }, + { "title": "Nights of Cabiria", "year": 1957 }, + { "title": "Amarcord", "year": 1973 }, + { "title": "L'Avventura", "year": 1960 }, + { "title": "The 400 Blows", "year": 1959 }, + { "title": "Breathless", "year": 1960 }, + { "title": "Contempt", "year": 1963 }, + { "title": "Playtime", "year": 1967 }, + { "title": "Mon Oncle", "year": 1958 }, + { "title": "Le Samourai", "year": 1967 }, + { "title": "Diabolique", "year": 1955 }, + { "title": "The Third Man", "year": 1949 }, + { "title": "Rebecca", "year": 1940 }, + { "title": "M", "year": 1931 }, + { "title": "Metropolis", "year": 1927 }, + { "title": "Beauty and the Beast", "year": 1946 }, + { "title": "Orpheus", "year": 1950 }, + { "title": "Tokyo Story", "year": 1953 }, + { "title": "Late Spring", "year": 1949 }, + { "title": "Ugetsu", "year": 1953 }, + { "title": "Sansho the Bailiff", "year": 1954 }, + { "title": "Harakiri", "year": 1962 }, + { "title": "Onibaba", "year": 1964 }, + { "title": "Woman in the Dunes", "year": 1964 }, + { "title": "Kwaidan", "year": 1964 }, + { "title": "In the Mood for Love", "year": 2000 }, + { "title": "Chungking Express", "year": 1994 }, + { "title": "Branded to Kill", "year": 1967 }, + { "title": "Tokyo Drifter", "year": 1966 }, + { "title": "House", "year": 1977 }, + { "title": "The Killing", "year": 1956 }, + { "title": "Paths of Glory", "year": 1957 }, + { "title": "Dr. Strangelove", "year": 1964 }, + { "title": "Rosemary's Baby", "year": 1968 }, + { "title": "Repo Man", "year": 1984 }, + { "title": "The Man Who Fell to Earth", "year": 1976 }, + { "title": "Blood Simple", "year": 1984 }, + { "title": "Do the Right Thing", "year": 1989 }, + { "title": "Malcolm X", "year": 1992 }, + { "title": "Hoop Dreams", "year": 1994 }, + { "title": "Chimes at Midnight", "year": 1965 }, + { "title": "Sullivan's Travels", "year": 1941 }, + { "title": "The Lady Eve", "year": 1941 }, + { "title": "Sweet Smell of Success", "year": 1957 }, + { "title": "Rififi", "year": 1955 }, + { "title": "Charade", "year": 1963 } +] +``` + +- [ ] **Step 4: Create the loader** + +Create `src/lib/criterion-catalog.ts`: + +```typescript +// src/lib/criterion-catalog.ts +import catalog from '../../data/criterion-catalog.json' + +export interface CriterionCatalogEntry { + title: string + year?: number +} + +export function getCriterionCatalog(): CriterionCatalogEntry[] { + return catalog as CriterionCatalogEntry[] +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run tests/criterion-catalog.test.ts` +Expected: PASS (3 tests) + +- [ ] **Step 6: Commit** + +```bash +git add data/criterion-catalog.json src/lib/criterion-catalog.ts tests/criterion-catalog.test.ts +git commit -m "feat: add bundled Criterion catalog data file and loader" +``` + +--- + +### Task 3: TMDB popular-movies helper + fetch timeouts + +**Files:** +- Modify: `src/lib/tmdb.ts` +- Test: `tests/tmdb.test.ts` + +**Interfaces:** +- Consumes: existing `fetchDetails(tmdbId, key)` (module-private), `getConfig()` from `@/lib/config`, `TmdbMovieDetails` from `@/types`. +- Produces: `fetchPopularMovies(page: number): Promise` — later tasks (Task 4) call this during deck refill. + +- [ ] **Step 1: Write the failing test** + +Add to `tests/tmdb.test.ts` (append a new `describe` block, and add the new import to the existing destructured `await import('@/lib/tmdb')` line at the top of the file): + +```typescript +// change the top-of-file import line to also pull in fetchPopularMovies: +// const { findByImdbId, searchByTitle, lookupCriterionSlug, fetchWatchProviders, fetchProviderList, fetchPopularMovies } = await import('@/lib/tmdb') +``` + +```typescript +describe('fetchPopularMovies', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.mocked(getConfig).mockResolvedValue(mockConfig) + }) + + it('resolves full details for each popular result', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ results: [{ id: 345911 }] }), + }) + .mockResolvedValueOnce({ ok: true, json: async () => detailsResponse }) + + const result = await fetchPopularMovies(1) + expect(result).toHaveLength(1) + expect(result[0].title).toBe('Seven Samurai') + }) + + it('returns empty array on fetch error', async () => { + mockFetch.mockResolvedValueOnce({ ok: false }) + expect(await fetchPopularMovies(1)).toEqual([]) + }) + + it('filters out results TMDB details couldn\'t resolve', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ results: [{ id: 1 }, { id: 2 }] }), + }) + .mockResolvedValueOnce({ ok: false }) // details for id 1 fail + .mockResolvedValueOnce({ ok: true, json: async () => detailsResponse }) // id 2 succeeds + + const result = await fetchPopularMovies(1) + expect(result).toHaveLength(1) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/tmdb.test.ts` +Expected: FAIL — `fetchPopularMovies is not a function` / import error + +- [ ] **Step 3: Implement `fetchPopularMovies` and add fetch timeouts** + +In `src/lib/tmdb.ts`, add a timeout wrapper and use it inside `fetchDetails` and `searchByTitle`'s search call, then add the new function. Replace the top of the file: + +```typescript +// src/lib/tmdb.ts +import type { TmdbMovieDetails } from '@/types' +import { getConfig } from './config' + +const BASE = 'https://api.themoviedb.org/3' +const IMG_BASE = 'https://image.tmdb.org/t/p/w500' +const FETCH_TIMEOUT_MS = 5000 + +function fetchWithTimeout(url: string): Promise { + return fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) +} + +async function fetchDetails(tmdbId: number, key: string): Promise { + const res = await fetchWithTimeout(`${BASE}/movie/${tmdbId}?api_key=${key}`) + if (!res.ok) return null + const m = await res.json() + return { + tmdbId: m.id, + title: m.title, + year: parseInt((m.release_date || '0').split('-')[0], 10) || 0, + runtime: m.runtime ?? 0, + description: m.overview ?? '', + posterUrl: m.poster_path ? `${IMG_BASE}${m.poster_path}` : '', + imdbId: m.imdb_id ?? '', + } +} +``` + +Update `searchByTitle`'s own fetch call to use `fetchWithTimeout` instead of `fetch`: + +```typescript +export async function searchByTitle( + title: string, + year?: number +): Promise { + const { tmdbApiKey } = await getConfig() + const yearParam = year ? `&year=${year}` : '' + const res = await fetchWithTimeout( + `${BASE}/search/movie?api_key=${tmdbApiKey}&query=${encodeURIComponent(title)}${yearParam}` + ) + if (!res.ok) return null + const data = await res.json() + const hit = data.results?.[0] + if (!hit) return null + return fetchDetails(hit.id, tmdbApiKey) +} +``` + +Add the new function at the end of the file (after `fetchProviderList`): + +```typescript +export async function fetchPopularMovies(page: number): Promise { + const { tmdbApiKey } = await getConfig() + const res = await fetchWithTimeout(`${BASE}/movie/popular?api_key=${tmdbApiKey}&page=${page}`) + if (!res.ok) return [] + const data = await res.json() + const hits = (data.results ?? []) as Array<{ id: number }> + const details = await Promise.all(hits.map((h) => fetchDetails(h.id, tmdbApiKey))) + return details.filter((d): d is TmdbMovieDetails => d !== null) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/tmdb.test.ts` +Expected: PASS, all tests in the file (existing + 3 new) green + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/tmdb.ts tests/tmdb.test.ts +git commit -m "feat: add fetchPopularMovies helper and request timeouts to tmdb.ts" +``` + +--- + +### Task 4: Deck refill logic + +**Files:** +- Create: `src/lib/match-night.ts` +- Test: `tests/match-night.test.ts` + +**Interfaces:** +- Consumes: `getCriterionCatalog()` from `@/lib/criterion-catalog`, `searchByTitle` / `fetchPopularMovies` from `@/lib/tmdb`, `prisma` from `@/lib/db`. +- Produces: `refillCandidates(): Promise` (returns count of newly inserted candidates) — consumed by Task 5's `getNextCandidateForUser`. +- Produces internally (not exported): `REFILL_THRESHOLD = 5`, `REFILL_BATCH_SIZE = 20` constants — Task 5 imports `REFILL_THRESHOLD` too. + +- [ ] **Step 1: Write the failing test** + +Create `tests/match-night.test.ts`: + +```typescript +// tests/match-night.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/db', () => { + const prisma: any = { + movie: { findMany: vi.fn(), aggregate: vi.fn(), create: vi.fn(), findUnique: vi.fn(), findUniqueOrThrow: vi.fn() }, + swipeCandidate: { + findMany: vi.fn(), createMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn(), update: vi.fn(), count: vi.fn(), + }, + swipe: { create: vi.fn(), findUnique: vi.fn() }, + setting: { findUnique: vi.fn(), upsert: vi.fn() }, + } + prisma.$transaction = vi.fn((cb: any) => cb(prisma)) + return { prisma } +}) +vi.mock('@/lib/criterion-catalog', () => ({ + getCriterionCatalog: vi.fn(), +})) +vi.mock('@/lib/tmdb', () => ({ + searchByTitle: vi.fn(), + fetchPopularMovies: vi.fn(), +})) + +import { prisma } from '@/lib/db' +import { getCriterionCatalog } from '@/lib/criterion-catalog' +import { searchByTitle, fetchPopularMovies } from '@/lib/tmdb' +import { refillCandidates } from '@/lib/match-night' + +const tmdbDetails = (overrides: Partial = {}) => ({ + tmdbId: 1, title: 'Some Film', year: 1960, runtime: 90, + description: 'desc', posterUrl: 'poster.jpg', imdbId: 'tt0000001', + ...overrides, +}) + +describe('refillCandidates', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(prisma.movie.findMany).mockResolvedValue([]) + vi.mocked(prisma.swipeCandidate.findMany).mockResolvedValue([]) + vi.mocked(prisma.setting.findUnique).mockResolvedValue(null) + vi.mocked(prisma.setting.upsert).mockResolvedValue({} as any) + vi.mocked(prisma.swipeCandidate.createMany).mockResolvedValue({ count: 0 } as any) + }) + + it('resolves catalog entries via TMDB and inserts them as criterion candidates', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([{ title: 'Seven Samurai', year: 1954 }]) + vi.mocked(searchByTitle).mockResolvedValue(tmdbDetails({ tmdbId: 345911, title: 'Seven Samurai' })) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + + const count = await refillCandidates() + + expect(count).toBe(1) + expect(prisma.swipeCandidate.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ tmdbId: 345911, source: 'criterion' })], + }) + }) + + it('skips a resolved title that already exists as a Movie', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([{ title: 'Seven Samurai', year: 1954 }]) + vi.mocked(prisma.movie.findMany).mockResolvedValue([{ tmdbId: 345911 }] as any) + vi.mocked(searchByTitle).mockResolvedValue(tmdbDetails({ tmdbId: 345911 })) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + + const count = await refillCandidates() + expect(count).toBe(0) + }) + + it('skips a resolved title that already exists as a SwipeCandidate', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([{ title: 'Seven Samurai', year: 1954 }]) + vi.mocked(prisma.swipeCandidate.findMany).mockResolvedValue([{ tmdbId: 345911 }] as any) + vi.mocked(searchByTitle).mockResolvedValue(tmdbDetails({ tmdbId: 345911 })) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + + const count = await refillCandidates() + expect(count).toBe(0) + }) + + it('adds TMDB popular results as tmdb-source candidates', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(fetchPopularMovies).mockResolvedValue([tmdbDetails({ tmdbId: 99 })]) + + const count = await refillCandidates() + expect(count).toBe(1) + expect(prisma.swipeCandidate.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ tmdbId: 99, source: 'tmdb' })], + }) + }) + + it('continues with TMDB-only results when a Criterion title fails to resolve', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([{ title: 'Unresolvable Title' }]) + vi.mocked(searchByTitle).mockRejectedValue(new Error('timeout')) + vi.mocked(fetchPopularMovies).mockResolvedValue([tmdbDetails({ tmdbId: 99 })]) + + const count = await refillCandidates() + expect(count).toBe(1) + expect(prisma.swipeCandidate.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ tmdbId: 99, source: 'tmdb' })], + }) + }) + + it('persists the TMDB popular page cursor after a refill', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(prisma.setting.findUnique).mockResolvedValue({ key: 'match_night_tmdb_popular_page', value: '3' } as any) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + + await refillCandidates() + expect(fetchPopularMovies).toHaveBeenCalledWith(3) + expect(prisma.setting.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { key: 'match_night_tmdb_popular_page' } }) + ) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/match-night.test.ts` +Expected: FAIL — `Cannot find module '@/lib/match-night'` + +- [ ] **Step 3: Implement `src/lib/match-night.ts` (refill portion only)** + +```typescript +// src/lib/match-night.ts +import { prisma } from './db' +import { getCriterionCatalog } from './criterion-catalog' +import { searchByTitle, fetchPopularMovies } from './tmdb' +import type { TmdbMovieDetails } from '@/types' + +export const REFILL_THRESHOLD = 5 +export const REFILL_BATCH_SIZE = 20 + +const CRITERION_CURSOR_KEY = 'match_night_criterion_cursor' +const TMDB_PAGE_CURSOR_KEY = 'match_night_tmdb_popular_page' +const MAX_TMDB_PAGES_PER_REFILL = 5 + +interface NewCandidate extends TmdbMovieDetails { + source: 'criterion' | 'tmdb' +} + +async function getCursor(key: string): Promise { + const row = await prisma.setting.findUnique({ where: { key } }) + return row ? parseInt(row.value, 10) || 0 : 0 +} + +async function setCursor(key: string, value: number): Promise { + await prisma.setting.upsert({ + where: { key }, + create: { key, value: String(value) }, + update: { value: String(value) }, + }) +} + +async function getExistingTmdbIds(): Promise> { + const [movies, candidates] = await Promise.all([ + prisma.movie.findMany({ select: { tmdbId: true } }), + prisma.swipeCandidate.findMany({ select: { tmdbId: true } }), + ]) + return new Set([ + ...movies.map((m) => m.tmdbId), + ...candidates.map((c) => c.tmdbId), + ]) +} + +export async function refillCandidates(): Promise { + const existingTmdbIds = await getExistingTmdbIds() + const toInsert: NewCandidate[] = [] + const halfBatch = REFILL_BATCH_SIZE / 2 + + // Criterion: resolve forward from the saved cursor through the static catalog + const catalog = getCriterionCatalog() + let criterionCursor = await getCursor(CRITERION_CURSOR_KEY) + const criterionCollected = () => toInsert.filter((c) => c.source === 'criterion').length + + while (criterionCursor < catalog.length && criterionCollected() < halfBatch) { + const entry = catalog[criterionCursor] + criterionCursor++ + const details = await searchByTitle(entry.title, entry.year).catch(() => null) + if (details && !existingTmdbIds.has(details.tmdbId)) { + existingTmdbIds.add(details.tmdbId) + toInsert.push({ ...details, source: 'criterion' }) + } + } + await setCursor(CRITERION_CURSOR_KEY, criterionCursor) + + // TMDB popular: page forward from the saved cursor + let page = (await getCursor(TMDB_PAGE_CURSOR_KEY)) || 1 + let pagesFetched = 0 + + while (toInsert.length < REFILL_BATCH_SIZE && pagesFetched < MAX_TMDB_PAGES_PER_REFILL) { + const results = await fetchPopularMovies(page).catch(() => []) + pagesFetched++ + page++ + for (const details of results) { + if (existingTmdbIds.has(details.tmdbId)) continue + existingTmdbIds.add(details.tmdbId) + toInsert.push({ ...details, source: 'tmdb' }) + if (toInsert.length >= REFILL_BATCH_SIZE) break + } + } + await setCursor(TMDB_PAGE_CURSOR_KEY, page) + + if (toInsert.length > 0) { + await prisma.swipeCandidate.createMany({ data: toInsert }) + } + return toInsert.length +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/match-night.test.ts` +Expected: PASS (6 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/match-night.ts tests/match-night.test.ts +git commit -m "feat: add Match Night deck refill logic with cursor-tracked sourcing" +``` + +--- + +### Task 5: Next-candidate lookup and swipe/match transaction + +**Files:** +- Modify: `src/lib/match-night.ts` +- Modify: `tests/match-night.test.ts` + +**Interfaces:** +- Consumes: `otherUser` from `@/lib/user-utils`, `Prisma` from `@prisma/client`, `syncMovieProviders` from `@/lib/streaming`, `REFILL_THRESHOLD`/`refillCandidates` (same file, Task 4). +- Produces: `getNextCandidateForUser(user: User): Promise` and `recordSwipe(candidateId: number, user: User, vote: SwipeVote): Promise` where `SwipeResult = { status: 'recorded' } | { status: 'matched'; movie: Movie } | { status: 'ignored' }` — Task 6's API routes call both. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/match-night.test.ts` (update the mocks at the top first — add `deleteMany` is not needed, but add `vi.mock('@/lib/streaming', ...)`): + +Add this mock near the top of the file, alongside the other `vi.mock` calls: + +```typescript +vi.mock('@/lib/streaming', () => ({ syncMovieProviders: vi.fn().mockResolvedValue(undefined) })) +``` + +Update the import line to add the two new functions: + +```typescript +import { refillCandidates, getNextCandidateForUser, recordSwipe } from '@/lib/match-night' +``` + +Add new `describe` blocks: + +```typescript +describe('getNextCandidateForUser', () => { + beforeEach(() => vi.clearAllMocks()) + + it('returns the next candidate without refilling when the pending count is at or above threshold', async () => { + vi.mocked(prisma.swipeCandidate.count).mockResolvedValue(5) + const candidate = { id: 5, status: 'pending' } + vi.mocked(prisma.swipeCandidate.findFirst).mockResolvedValue(candidate as any) + + const result = await getNextCandidateForUser('user1') + + expect(result).toEqual(candidate) + expect(prisma.swipeCandidate.count).toHaveBeenCalledWith({ + where: { status: 'pending', swipes: { none: { user: 'user1' } } }, + }) + expect(prisma.swipeCandidate.findFirst).toHaveBeenCalledWith({ + where: { status: 'pending', swipes: { none: { user: 'user1' } } }, + orderBy: { createdAt: 'asc' }, + }) + expect(getCriterionCatalog).not.toHaveBeenCalled() + }) + + it('refills before returning when the pending count is below threshold', async () => { + vi.mocked(prisma.swipeCandidate.count).mockResolvedValue(2) + vi.mocked(prisma.swipeCandidate.findFirst).mockResolvedValue({ id: 7 } as any) + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(fetchPopularMovies).mockResolvedValue([tmdbDetails({ tmdbId: 42 })]) + vi.mocked(prisma.movie.findMany).mockResolvedValue([]) + vi.mocked(prisma.swipeCandidate.findMany).mockResolvedValue([]) + vi.mocked(prisma.setting.findUnique).mockResolvedValue(null) + + const result = await getNextCandidateForUser('user1') + + expect(result).toEqual({ id: 7 }) + expect(getCriterionCatalog).toHaveBeenCalled() + expect(prisma.swipeCandidate.createMany).toHaveBeenCalled() + }) + + it('returns null when the pending count is below threshold and a refill adds nothing', async () => { + vi.mocked(prisma.swipeCandidate.count).mockResolvedValue(0) + vi.mocked(prisma.swipeCandidate.findFirst).mockResolvedValue(null) + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + vi.mocked(prisma.movie.findMany).mockResolvedValue([]) + vi.mocked(prisma.swipeCandidate.findMany).mockResolvedValue([]) + vi.mocked(prisma.setting.findUnique).mockResolvedValue(null) + + const result = await getNextCandidateForUser('user1') + + expect(result).toBeNull() + }) +}) + +describe('recordSwipe', () => { + beforeEach(() => vi.clearAllMocks()) + + const pendingCandidate = { + id: 1, tmdbId: 345911, imdbId: 'tt0047478', title: 'Seven Samurai', + year: 1954, runtime: 207, description: 'desc', posterUrl: 'p.jpg', status: 'pending', + } + + it('marks the candidate dead on a down vote and does not touch Movie', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + + const result = await recordSwipe(1, 'user1', 'down') + + expect(result).toEqual({ status: 'recorded' }) + expect(prisma.swipe.create).toHaveBeenCalledWith({ data: { candidateId: 1, user: 'user1', vote: 'down' } }) + expect(prisma.swipeCandidate.update).toHaveBeenCalledWith({ where: { id: 1 }, data: { status: 'dead' } }) + expect(prisma.movie.create).not.toHaveBeenCalled() + }) + + it('just records the vote when the other user has not voted up yet', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + vi.mocked(prisma.swipe.findUnique).mockResolvedValue(null) + + const result = await recordSwipe(1, 'user1', 'up') + + expect(result).toEqual({ status: 'recorded' }) + expect(prisma.movie.create).not.toHaveBeenCalled() + }) + + it('creates the Movie and marks matched when both users are up', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + vi.mocked(prisma.swipe.findUnique).mockResolvedValue({ candidateId: 1, user: 'user2', vote: 'up' } as any) + vi.mocked(prisma.movie.aggregate).mockResolvedValue({ _max: { sortOrder: 4 } } as any) + const createdMovie = { id: 10, title: 'Seven Samurai', tmdbId: 345911 } + vi.mocked(prisma.movie.create).mockResolvedValue(createdMovie as any) + + const result = await recordSwipe(1, 'user1', 'up') + + expect(result).toEqual({ status: 'matched', movie: createdMovie }) + expect(prisma.movie.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tmdbId: 345911, title: 'Seven Samurai', sortOrder: 5, matchedViaSwipe: true, + }), + }) + expect(prisma.swipeCandidate.update).toHaveBeenCalledWith({ where: { id: 1 }, data: { status: 'matched' } }) + }) + + it('triggers syncMovieProviders after a match', async () => { + const { syncMovieProviders } = await import('@/lib/streaming') + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + vi.mocked(prisma.swipe.findUnique).mockResolvedValue({ vote: 'up' } as any) + vi.mocked(prisma.movie.aggregate).mockResolvedValue({ _max: { sortOrder: 0 } } as any) + vi.mocked(prisma.movie.create).mockResolvedValue({ id: 10, tmdbId: 345911 } as any) + + await recordSwipe(1, 'user1', 'up') + await new Promise((r) => setTimeout(r, 0)) + expect(syncMovieProviders).toHaveBeenCalledWith(10, 345911) + }) + + it('is a no-op when the candidate is no longer pending', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue({ ...pendingCandidate, status: 'dead' } as any) + + const result = await recordSwipe(1, 'user1', 'up') + + expect(result).toEqual({ status: 'ignored' }) + expect(prisma.swipe.create).not.toHaveBeenCalled() + }) + + it('is a no-op when the candidate does not exist', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(null) + + const result = await recordSwipe(999, 'user1', 'up') + expect(result).toEqual({ status: 'ignored' }) + }) + + it('falls back to the existing Movie on a unique constraint race instead of erroring', async () => { + const { Prisma } = await import('@prisma/client') + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + vi.mocked(prisma.swipe.findUnique).mockResolvedValue({ vote: 'up' } as any) + vi.mocked(prisma.movie.aggregate).mockResolvedValue({ _max: { sortOrder: 0 } } as any) + const p2002 = new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { + code: 'P2002', clientVersion: '7.0.0', + }) + vi.mocked(prisma.movie.create).mockRejectedValue(p2002) + const existingMovie = { id: 10, tmdbId: 345911 } + vi.mocked(prisma.movie.findUniqueOrThrow).mockResolvedValue(existingMovie as any) + + const result = await recordSwipe(1, 'user1', 'up') + expect(result).toEqual({ status: 'matched', movie: existingMovie }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/match-night.test.ts` +Expected: FAIL — `getNextCandidateForUser`/`recordSwipe` not exported + +- [ ] **Step 3: Implement the rest of `src/lib/match-night.ts`** + +Add these imports to the top of `src/lib/match-night.ts` (alongside the existing ones): + +```typescript +import { Prisma } from '@prisma/client' +import { otherUser } from './user-utils' +import { syncMovieProviders } from './streaming' +import type { Movie, SwipeCandidateRecord, SwipeVote, User } from '@/types' +``` + +Append to the end of `src/lib/match-night.ts`: + +```typescript +export async function getNextCandidateForUser(user: User): Promise { + const pendingCount = await prisma.swipeCandidate.count({ + where: { status: 'pending', swipes: { none: { user } } }, + }) + if (pendingCount < REFILL_THRESHOLD) { + await refillCandidates() + } + + return (await findNextPending(user)) as unknown as SwipeCandidateRecord | null +} + +function findNextPending(user: User) { + return prisma.swipeCandidate.findFirst({ + where: { status: 'pending', swipes: { none: { user } } }, + orderBy: { createdAt: 'asc' }, + }) +} + +export type SwipeResult = + | { status: 'recorded' } + | { status: 'matched'; movie: Movie } + | { status: 'ignored' } + +export async function recordSwipe( + candidateId: number, + user: User, + vote: SwipeVote +): Promise { + return prisma.$transaction(async (tx) => { + const candidate = await tx.swipeCandidate.findUnique({ where: { id: candidateId } }) + if (!candidate || candidate.status !== 'pending') { + return { status: 'ignored' } + } + + await tx.swipe.create({ data: { candidateId, user, vote } }) + + if (vote === 'down') { + await tx.swipeCandidate.update({ where: { id: candidateId }, data: { status: 'dead' } }) + return { status: 'recorded' } + } + + const other = otherUser(user) + const otherSwipe = await tx.swipe.findUnique({ + where: { candidateId_user: { candidateId, user: other } }, + }) + if (!otherSwipe || otherSwipe.vote !== 'up') { + return { status: 'recorded' } + } + + const { _max } = await tx.movie.aggregate({ _max: { sortOrder: true } }) + let movie + try { + movie = await tx.movie.create({ + data: { + title: candidate.title, + year: candidate.year, + runtime: candidate.runtime, + description: candidate.description, + posterUrl: candidate.posterUrl, + imdbId: candidate.imdbId, + tmdbId: candidate.tmdbId, + sortOrder: (_max.sortOrder ?? 0) + 1, + matchedViaSwipe: true, + }, + }) + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + movie = await tx.movie.findUniqueOrThrow({ where: { tmdbId: candidate.tmdbId } }) + } else { + throw err + } + } + + await tx.swipeCandidate.update({ where: { id: candidateId }, data: { status: 'matched' } }) + return { status: 'matched', movie } + }).then((result: SwipeResult) => { + if (result.status === 'matched') { + syncMovieProviders(result.movie.id, result.movie.tmdbId).catch((err) => + console.error('[match-night] Failed to sync providers for matched movie:', err) + ) + } + return result + }) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/match-night.test.ts` +Expected: PASS (all tests in the file, ~14 total) + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/match-night.ts tests/match-night.test.ts +git commit -m "feat: add Match Night swipe/match transaction with concurrency safeguards" +``` + +--- + +### Task 6: API routes + +**Files:** +- Create: `src/app/api/match-night/next/route.ts` +- Create: `src/app/api/match-night/swipe/route.ts` +- Test: `tests/api.match-night.test.ts` + +**Interfaces:** +- Consumes: `getNextCandidateForUser`, `recordSwipe` from `@/lib/match-night` (Task 5); `USER_KEYS` from `@/lib/user-utils`. +- Produces: `GET /api/match-night/next?user=` → `{ candidate: SwipeCandidateRecord | null }`; `POST /api/match-night/swipe` (body `{ candidateId, user, vote }`) → the `SwipeResult` JSON — consumed by Task 8's page. + +- [ ] **Step 1: Write the failing test** + +Create `tests/api.match-night.test.ts`: + +```typescript +// tests/api.match-night.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/match-night', () => ({ + getNextCandidateForUser: vi.fn(), + recordSwipe: vi.fn(), +})) + +import { getNextCandidateForUser, recordSwipe } from '@/lib/match-night' +import { GET } from '@/app/api/match-night/next/route' +import { POST } from '@/app/api/match-night/swipe/route' + +describe('GET /api/match-night/next', () => { + beforeEach(() => vi.clearAllMocks()) + + it('returns the next candidate for a valid user', async () => { + vi.mocked(getNextCandidateForUser).mockResolvedValue({ id: 1, title: 'Seven Samurai' } as any) + const req = new Request('http://localhost/api/match-night/next?user=user1') + const res = await GET(req) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ candidate: { id: 1, title: 'Seven Samurai' } }) + expect(getNextCandidateForUser).toHaveBeenCalledWith('user1') + }) + + it('returns candidate: null when the deck is empty', async () => { + vi.mocked(getNextCandidateForUser).mockResolvedValue(null) + const req = new Request('http://localhost/api/match-night/next?user=user2') + const res = await GET(req) + expect(await res.json()).toEqual({ candidate: null }) + }) + + it('returns 422 for a missing/invalid user', async () => { + const req = new Request('http://localhost/api/match-night/next?user=nobody') + const res = await GET(req) + expect(res.status).toBe(422) + }) +}) + +describe('POST /api/match-night/swipe', () => { + beforeEach(() => vi.clearAllMocks()) + + it('records a valid swipe', async () => { + vi.mocked(recordSwipe).mockResolvedValue({ status: 'recorded' }) + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ candidateId: 1, user: 'user1', vote: 'up' }), + }) + const res = await POST(req) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ status: 'recorded' }) + expect(recordSwipe).toHaveBeenCalledWith(1, 'user1', 'up') + }) + + it('returns the matched movie payload on a match', async () => { + vi.mocked(recordSwipe).mockResolvedValue({ status: 'matched', movie: { id: 10 } as any }) + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ candidateId: 1, user: 'user2', vote: 'up' }), + }) + const res = await POST(req) + expect(await res.json()).toEqual({ status: 'matched', movie: { id: 10 } }) + }) + + it('returns 422 for an invalid user', async () => { + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ candidateId: 1, user: 'nobody', vote: 'up' }), + }) + expect((await POST(req)).status).toBe(422) + }) + + it('returns 422 for a missing candidateId', async () => { + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ user: 'user1', vote: 'up' }), + }) + expect((await POST(req)).status).toBe(422) + }) + + it('returns 422 for an invalid vote value', async () => { + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ candidateId: 1, user: 'user1', vote: 'sideways' }), + }) + expect((await POST(req)).status).toBe(422) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/api.match-night.test.ts` +Expected: FAIL — route modules don't exist + +- [ ] **Step 3: Implement the routes** + +Create `src/app/api/match-night/next/route.ts`: + +```typescript +// src/app/api/match-night/next/route.ts +import { NextResponse } from 'next/server' +import { getNextCandidateForUser } from '@/lib/match-night' +import { USER_KEYS } from '@/lib/user-utils' +import type { User } from '@/types' + +export const dynamic = 'force-dynamic' + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url) + const user = searchParams.get('user') + if (!USER_KEYS.includes(user as User)) { + return NextResponse.json({ error: 'invalid user' }, { status: 422 }) + } + + const candidate = await getNextCandidateForUser(user as User) + return NextResponse.json({ candidate }) +} +``` + +Create `src/app/api/match-night/swipe/route.ts`: + +```typescript +// src/app/api/match-night/swipe/route.ts +import { NextResponse } from 'next/server' +import { recordSwipe } from '@/lib/match-night' +import { USER_KEYS } from '@/lib/user-utils' +import type { SwipeVote, User } from '@/types' + +interface SwipeBody { + candidateId?: number + user?: User + vote?: SwipeVote +} + +export async function POST(req: Request) { + const body = (await req.json().catch(() => ({}))) as SwipeBody + + if (!body.candidateId || !USER_KEYS.includes(body.user as User)) { + return NextResponse.json({ error: 'invalid request' }, { status: 422 }) + } + if (body.vote !== 'up' && body.vote !== 'down') { + return NextResponse.json({ error: 'vote must be "up" or "down"' }, { status: 422 }) + } + + const result = await recordSwipe(body.candidateId, body.user as User, body.vote) + return NextResponse.json(result) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/api.match-night.test.ts` +Expected: PASS (8 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/app/api/match-night tests/api.match-night.test.ts +git commit -m "feat: add /api/match-night/next and /api/match-night/swipe routes" +``` + +--- + +### Task 7: `MatchNightCard` component + +**Files:** +- Create: `src/components/match-night-card.tsx` +- Test: `tests/match-night-card.test.tsx` + +**Interfaces:** +- Consumes: `MoviePoster` from `./movie-poster`, `Button` from `@/components/ui/button`, `SwipeCandidateRecord` from `@/types`. +- Produces: `` — consumed by Task 8's page. + +- [ ] **Step 1: Write the failing test** + +Create `tests/match-night-card.test.tsx`: + +```typescript +// tests/match-night-card.test.tsx +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { MatchNightCard } from '@/components/match-night-card' +import type { SwipeCandidateRecord } from '@/types' + +vi.mock('next/image', () => ({ + default: ({ src, alt }: { src: string; alt: string }) => {alt}, +})) + +const candidate: SwipeCandidateRecord = { + id: 1, tmdbId: 345911, imdbId: 'tt0047478', title: 'Seven Samurai', year: 1954, + runtime: 207, description: 'A poor village recruits seven samurai.', posterUrl: 'poster.jpg', + source: 'criterion', status: 'pending', createdAt: new Date().toISOString(), +} + +describe('MatchNightCard', () => { + it('renders the title, year, and description', () => { + render() + expect(screen.getByText('Seven Samurai')).toBeInTheDocument() + expect(screen.getByText('1954')).toBeInTheDocument() + expect(screen.getByText(/seven samurai\.$/i)).toBeInTheDocument() + }) + + it('calls onVote("up") when the thumbs-up button is clicked', () => { + const onVote = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /thumbs up/i })) + expect(onVote).toHaveBeenCalledWith('up') + }) + + it('calls onVote("down") when the thumbs-down button is clicked', () => { + const onVote = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /thumbs down/i })) + expect(onVote).toHaveBeenCalledWith('down') + }) + + it('disables both buttons while voting', () => { + render() + expect(screen.getByRole('button', { name: /thumbs up/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /thumbs down/i })).toBeDisabled() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/match-night-card.test.tsx` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement the component** + +Create `src/components/match-night-card.tsx`: + +```typescript +// src/components/match-night-card.tsx +'use client' +import { MoviePoster } from './movie-poster' +import { Button } from '@/components/ui/button' +import type { SwipeCandidateRecord, SwipeVote } from '@/types' + +interface MatchNightCardProps { + candidate: SwipeCandidateRecord + voting: boolean + onVote: (vote: SwipeVote) => void +} + +export function MatchNightCard({ candidate, voting, onVote }: MatchNightCardProps) { + return ( +
+ +
+

{candidate.title}

+

{candidate.year}

+

{candidate.description}

+
+
+ + +
+
+ ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/match-night-card.test.tsx` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/components/match-night-card.tsx tests/match-night-card.test.tsx +git commit -m "feat: add MatchNightCard component" +``` + +--- + +### Task 8: Match Night page + +**Files:** +- Create: `src/app/match-night/page.tsx` +- Test: `tests/match-night-page.test.tsx` + +**Interfaces:** +- Consumes: `MatchNightCard` (Task 7), `USER_KEYS` from `@/lib/user-utils`, `/api/user-names`, `/api/match-night/next`, `/api/match-night/swipe`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/match-night-page.test.tsx`: + +```typescript +// tests/match-night-page.test.tsx +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import MatchNightPage from '@/app/match-night/page' + +vi.mock('next/image', () => ({ + default: ({ src, alt }: { src: string; alt: string }) => {alt}, +})) + +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +const candidate = { + id: 1, tmdbId: 345911, imdbId: 'tt0047478', title: 'Seven Samurai', year: 1954, + runtime: 207, description: 'desc', posterUrl: 'poster.jpg', source: 'criterion', + status: 'pending', createdAt: new Date().toISOString(), +} + +function jsonResponse(data: unknown) { + return Promise.resolve({ ok: true, json: async () => data }) +} + +describe('MatchNightPage', () => { + beforeEach(() => { + mockFetch.mockReset() + mockFetch.mockImplementation((url: string) => { + if (url.includes('/api/user-names')) return jsonResponse({ user1: 'Ian', user2: 'Krista' }) + if (url.includes('/api/match-night/next')) return jsonResponse({ candidate }) + if (url.includes('/api/match-night/swipe')) return jsonResponse({ status: 'recorded' }) + return jsonResponse({}) + }) + }) + + it('shows a user picker before swiping starts', async () => { + render() + await waitFor(() => expect(screen.getByText('Ian')).toBeInTheDocument()) + expect(screen.getByText('Krista')).toBeInTheDocument() + }) + + it('loads the next candidate after picking a user', async () => { + render() + await waitFor(() => screen.getByText('Ian')) + fireEvent.click(screen.getByText('Ian')) + await waitFor(() => expect(screen.getByText('Seven Samurai')).toBeInTheDocument()) + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/match-night/next?user=user1')) + }) + + it('submits a swipe and requests the next card', async () => { + render() + await waitFor(() => screen.getByText('Ian')) + fireEvent.click(screen.getByText('Ian')) + await waitFor(() => screen.getByText('Seven Samurai')) + + fireEvent.click(screen.getByRole('button', { name: /thumbs up/i })) + + await waitFor(() => + expect(mockFetch).toHaveBeenCalledWith( + '/api/match-night/swipe', + expect.objectContaining({ method: 'POST' }) + ) + ) + }) + + it('requests the next card after a match, without showing any in-page match feedback', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.includes('/api/user-names')) return jsonResponse({ user1: 'Ian', user2: 'Krista' }) + if (url.includes('/api/match-night/next')) return jsonResponse({ candidate }) + if (url.includes('/api/match-night/swipe')) return jsonResponse({ status: 'matched', movie: { id: 10 } }) + return jsonResponse({}) + }) + render() + await waitFor(() => screen.getByText('Ian')) + fireEvent.click(screen.getByText('Ian')) + await waitFor(() => screen.getByText('Seven Samurai')) + fireEvent.click(screen.getByRole('button', { name: /thumbs up/i })) + await waitFor(() => + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/match-night/next?user=user1')) + ) + expect(screen.queryByText(/it's a match/i)).not.toBeInTheDocument() + }) + + it('shows an empty state when there is no next candidate', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.includes('/api/user-names')) return jsonResponse({ user1: 'Ian', user2: 'Krista' }) + if (url.includes('/api/match-night/next')) return jsonResponse({ candidate: null }) + return jsonResponse({}) + }) + render() + await waitFor(() => screen.getByText('Ian')) + fireEvent.click(screen.getByText('Ian')) + await waitFor(() => expect(screen.getByText(/all caught up/i)).toBeInTheDocument()) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/match-night-page.test.tsx` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement the page** + +Create `src/app/match-night/page.tsx`: + +```typescript +// src/app/match-night/page.tsx +'use client' +import { useState, useEffect, useCallback } from 'react' +import { MatchNightCard } from '@/components/match-night-card' +import { Button } from '@/components/ui/button' +import { USER_KEYS } from '@/lib/user-utils' +import type { SwipeCandidateRecord, SwipeVote, User } from '@/types' + +export default function MatchNightPage() { + const [userNames, setUserNames] = useState>({ user1: 'User 1', user2: 'User 2' }) + const [currentUser, setCurrentUser] = useState(null) + const [candidate, setCandidate] = useState(null) + const [loading, setLoading] = useState(false) + const [voting, setVoting] = useState(false) + + useEffect(() => { + fetch('/api/user-names') + .then((r) => r.json()) + .then(setUserNames) + .catch(() => {}) + }, []) + + const loadNext = useCallback(async (user: User) => { + setLoading(true) + try { + const res = await fetch(`/api/match-night/next?user=${user}`) + const data = await res.json() + setCandidate(data.candidate) + } finally { + setLoading(false) + } + }, []) + + const handleSelectUser = (user: User) => { + setCurrentUser(user) + loadNext(user) + } + + const handleVote = async (vote: SwipeVote) => { + if (!currentUser || !candidate) return + setVoting(true) + try { + await fetch('/api/match-night/swipe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ candidateId: candidate.id, user: currentUser, vote }), + }) + await loadNext(currentUser) + } finally { + setVoting(false) + } + } + + if (!currentUser) { + return ( +
+

Match Night 💕

+

Who's swiping?

+
+ {USER_KEYS.map((user) => ( + + ))} +
+
+ ) + } + + return ( +
+

Match Night 💕

+

Swiping as {userNames[currentUser]}

+ + {loading ? ( +
Loading next film…
+ ) : candidate ? ( + + ) : ( +
+
🎬
+

You're all caught up!

+

Check back later for more films to swipe on.

+
+ )} +
+ ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/match-night-page.test.tsx` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/app/match-night tests/match-night-page.test.tsx +git commit -m "feat: add Match Night swipe page" +``` + +--- + +### Task 9: Sidebar and mobile nav entries + +**Files:** +- Modify: `src/components/sidebar.tsx` +- Modify: `src/components/mobile-bottom-nav.tsx` +- Modify: `tests/sidebar.test.tsx` +- Modify: `tests/mobile-bottom-nav.test.tsx` + +**Interfaces:** +- No new exports — this task only adds a nav entry to each existing component's `navItems`/`tabs` array. + +- [ ] **Step 1: Update the failing assertions in existing tests** + +In `tests/sidebar.test.tsx`, add an assertion to the first test: + +```typescript + it('renders primary nav links', () => { + render() + expect(screen.getByRole('link', { name: /watch list/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /watched/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /add movie/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /recommend/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /match night/i })).toBeInTheDocument() + }) +``` + +In `tests/mobile-bottom-nav.test.tsx`, change the "four" test to five and add assertions: + +```typescript + it('renders all five navigation tabs', () => { + render() + expect(screen.getByText('List')).toBeInTheDocument() + expect(screen.getByText('Watched')).toBeInTheDocument() + expect(screen.getByText('Add')).toBeInTheDocument() + expect(screen.getByText('Recs')).toBeInTheDocument() + expect(screen.getByText('Match')).toBeInTheDocument() + }) +``` + +And add a route assertion inside the existing "links to the correct routes" test: + +```typescript + it('links to the correct routes', () => { + render() + expect(screen.getByRole('link', { name: /list/i })).toHaveAttribute('href', '/watchlist') + expect(screen.getByRole('link', { name: /watched/i })).toHaveAttribute('href', '/watched') + expect(screen.getByRole('link', { name: /add/i })).toHaveAttribute('href', '/add') + expect(screen.getByRole('link', { name: /recs/i })).toHaveAttribute('href', '/recommendations') + expect(screen.getByRole('link', { name: /match/i })).toHaveAttribute('href', '/match-night') + }) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run tests/sidebar.test.tsx tests/mobile-bottom-nav.test.tsx` +Expected: FAIL — "Match Night" / "Match" link not found + +- [ ] **Step 3: Add the nav entries** + +In `src/components/sidebar.tsx`, update `navItems`: + +```typescript +const navItems = [ + { href: '/watchlist', label: 'Watch List', icon: '📋' }, + { href: '/watched', label: 'Watched', icon: '✅' }, + { href: '/add', label: 'Add Movie', icon: '➕' }, + { href: '/match-night', label: 'Match Night', icon: '💕' }, + { href: '/recommendations', label: 'Recommend', icon: '🎯' }, +] +``` + +In `src/components/mobile-bottom-nav.tsx`, update `tabs`: + +```typescript +const tabs = [ + { href: '/watchlist', label: 'List', icon: '📋' }, + { href: '/watched', label: 'Watched', icon: '✅' }, + { href: '/add', label: 'Add', icon: '➕' }, + { href: '/match-night', label: 'Match', icon: '💕' }, + { href: '/recommendations', label: 'Recs', icon: '🎯' }, +] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run tests/sidebar.test.tsx tests/mobile-bottom-nav.test.tsx` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/components/sidebar.tsx src/components/mobile-bottom-nav.tsx tests/sidebar.test.tsx tests/mobile-bottom-nav.test.tsx +git commit -m "feat: add Match Night entry to sidebar and mobile bottom nav" +``` + +--- + +### Task 10: "It's a match!" badge on the watchlist + +**Files:** +- Modify: `src/components/movie-row.tsx` +- Modify: `tests/movie-row.test.tsx` + +**Interfaces:** +- Consumes: `movie.matchedViaSwipe` (Task 1's `Movie` type addition). + +- [ ] **Step 1: Write the failing test** + +Add to `tests/movie-row.test.tsx`, inside a new `describe` block: + +```typescript +describe('MovieRow match badge', () => { + beforeEach(() => mockFetch.mockReset()) + + it('shows the match badge when matchedViaSwipe is true', () => { + render() + expect(screen.getByText(/it's a match/i)).toBeInTheDocument() + }) + + it('does not show the match badge for regularly-added movies', () => { + render() + expect(screen.queryByText(/it's a match/i)).not.toBeInTheDocument() + }) +}) +``` + +Also add `matchedViaSwipe: false` to the `makeMovie()` default object in the same file (it's a required field per Task 1's type change): + +```typescript +function makeMovie(overrides: Partial = {}): Movie { + return { + id: 1, + title: 'Jeanne Dielman', + year: 1975, + runtime: 201, + description: '', + posterUrl: '', + imdbId: 'tt0073198', + tmdbId: 11650, + criterionUrl: null, + imdbUrl: null, + sortOrder: 1, + status: 'watchlist', + seerrRequestId: null, + seerrMediaId: null, + seerrStatus: 'not_requested', + watchedAt: null, + createdAt: new Date().toISOString(), + streamingLastChecked: new Date().toISOString(), + streamingLink: null, + matchedViaSwipe: false, + ratings: [], + streamingProviders: [], + ...overrides, + } +} +``` + +(If Task 1 already added this line while fixing `tsc` errors, skip this part — just add the two new tests.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/movie-row.test.tsx` +Expected: FAIL — badge text not found + +- [ ] **Step 3: Add the badge** + +In `src/components/movie-row.tsx`, inside the pills row (the `
` block), add the badge as the first pill: + +```tsx +
+ {movie.matchedViaSwipe && ( + + It's a match! 🎉 + + )} + {isStreamable && ( +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/movie-row.test.tsx` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/components/movie-row.tsx tests/movie-row.test.tsx +git commit -m "feat: show It's a match! badge on watchlist rows added via Match Night" +``` + +--- + +### Task 11: Full verification pass + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full test suite** + +Run: `npm run test:run` +Expected: all test files pass (previous count + this feature's new files), 0 failures + +- [ ] **Step 2: Run the linter** + +Run: `npm run lint` +Expected: no errors + +- [ ] **Step 3: Run the full typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 4: Run the build** + +Run: `npm run build` +Expected: build succeeds + +- [ ] **Step 5: Manual smoke test** + +Run: `npm run dev`, then in a browser: +1. Set `TMDB_API_KEY` in `.env.local` to a real key (required — Match Night can't resolve any candidates without it). +2. Visit `/match-night`, pick a user, confirm a card loads with poster/title/year/description. +3. Thumbs-down it, confirm a new card loads. +4. Thumbs-up a card, switch users (reload page, pick the other name), find the same title (deck order is FIFO so it should reappear for the other user), thumbs-up it too. +5. Visit `/watchlist`, confirm the matched movie appears with the "It's a match! 🎉" badge. + +No commit for this task — it's verification only. If any step fails, fix the underlying issue and re-run from Step 1. diff --git a/docs/superpowers/specs/2026-07-22-match-night-design.md b/docs/superpowers/specs/2026-07-22-match-night-design.md new file mode 100644 index 0000000..545bcf9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-match-night-design.md @@ -0,0 +1,213 @@ +# Match Night — Design Spec + +Date: 2026-07-22 + +## Summary + +A Tinder-style swipe feature ("Match Night") for discovering new movies to add to the +watchlist. Ian and Krista each swipe 👍/👎 through a shared deck of candidate movies, +independently and asynchronously. The instant both have thumbs-upped the same candidate, +it's automatically added to the watchlist, flagged with a "match" decorator. + +## Goals + +- Give both people a low-friction way to discover and agree on new movies, instead of + one person unilaterally pasting an IMDB/Criterion URL into Add Movie. +- Reuse the existing named-user pattern (no login) and the existing add-to-watchlist + pipeline (TMDB enrichment, streaming sync). +- Deck stays fresh automatically — no manual "load more" step required in normal use. + +## Non-goals + +- No drag/gesture-based swiping — tap buttons only, matching the rest of the app's + interaction style. +- No synchronous/shared-session swiping (passing one device back and forth). +- No notification/toast on match — surfaced passively via a watchlist card decorator. +- Not replacing the existing Add Movie flow (paste URL) — Match Night is an additional + discovery path into the same watchlist. + +## Data Model + +Two new Prisma models, plus one new field on `Movie`. + +```prisma +model SwipeCandidate { + id Int @id @default(autoincrement()) + tmdbId Int @unique + imdbId String? + title String + year Int + description String + posterUrl String + source String // "criterion" | "tmdb" + status String @default("pending") // pending | dead | matched + createdAt DateTime @default(now()) + swipes Swipe[] +} + +model Swipe { + id Int @id @default(autoincrement()) + candidateId Int + user String + vote String // "up" | "down" + swipedAt DateTime @default(now()) + candidate SwipeCandidate @relation(fields: [candidateId], references: [id], onDelete: Cascade) + + @@unique([candidateId, user]) +} +``` + +`Movie` model gets: + +```prisma +matchedViaSwipe Boolean @default(false) +``` + +Set `true` only when a `Movie` row is created as the result of a Match Night mutual +thumbs-up. Never set on movies added via the existing Add Movie / bulk import flows. +This flag is permanent history on that watchlist entry — there's nothing to dismiss or +clear. + +## Candidate Sourcing + +Two feeder sources, both writing into `SwipeCandidate`. + +> **Revision note (post-review):** the original design called for live-scraping +> `criterion.com/shop/browse/films`. Testing during planning showed criterion.com sits +> behind a Cloudflare JS challenge — a plain server-side `fetch` (to both listing pages +> and individual film pages) returns a "Just a moment..." challenge page, not real HTML. +> Live scraping the catalog is not viable. Source 1 below replaces the scrape with a +> bundled static file. + +1. **Criterion Collection** — a static JSON file checked into the repo, + `data/criterion-catalog.json`, containing `{ title: string, year?: number }` entries + for the Criterion Collection catalog. This is compiled once (semi-)manually (e.g. from + a public spine-number list) and updated by hand periodically as Criterion adds titles + — there is no live fetch to criterion.com in this feature. For each entry not yet + resolved, resolve via the existing `searchByTitle()` TMDB helper to get + tmdbId/imdbId/year/description/posterUrl, then insert as a `SwipeCandidate` with + `source: "criterion"`. +2. **TMDB popular** — page through TMDB's `/movie/popular` endpoint directly (new small + helper alongside the existing functions in `src/lib/tmdb.ts`). + +**Dedup rules**, applied when inserting fetched candidates: + +- Skip any title whose `tmdbId` or `imdbId` already matches an existing `Movie` row + (regardless of status — watchlist or watched). +- Skip any title whose `tmdbId` already exists as a `SwipeCandidate` (any status — + pending, dead, or matched), to avoid re-adding a candidate someone already killed or + matched on previously. + +**Refill trigger**: when a user requests their next card and their count of pending, +not-yet-swiped-by-them candidates drops below a threshold (5), the API kicks off a +fetch of one additional batch (e.g. 20) from both sources combined before responding. +This is a synchronous part of the request — no cron job, no manual refresh button. The +threshold (5) and batch size (20) are hardcoded constants for v1, not exposed in +Settings — unlike the streaming region/services settings, there's no user-facing reason +to tune these yet; revisit if usage shows otherwise. + +**Latency and failure handling**: a refill is a chain of sequential external HTTP calls +(per-title TMDB search for unresolved Criterion catalog entries, plus TMDB popular +paging), so `GET /api/match-night/next` can stall for a few seconds exactly when the +deck is running low — i.e. mid-swiping-session. Each external call gets a short timeout +(e.g. 5s) consistent with the rest of the app's "fail gracefully, return safe defaults" +pattern (per CLAUDE.md). The two sources are independent: if TMDB resolution fails or +times out for some Criterion catalog entries, log and skip those entries, continuing +with whatever did resolve plus the TMDB-popular batch, rather than aborting the whole +refill. If both sources fail or both are already exhausted, the refill simply adds zero +candidates and the request falls through to the empty-state response — no retry loop +within the request. + +## Swipe Flow + +New route `/match-night`, new sidebar entry "Match Night" with icon 💕 (added to +`navItems` in `src/components/sidebar.tsx`, and to the mobile bottom nav for parity with +other primary routes). Existing `navItems` labels are one or two words with a single +leading emoji glyph (e.g. "Watch List" 📋, "Recommend" 🎯) — "Match Night" fits that +pattern in length; do a quick visual check once built since it's slightly longer than +most existing entries. + +1. User selects their name via the same named-user selector pattern used elsewhere in + the app (e.g. rating flow) — no separate login. +2. Page requests "next card for this user": the oldest `pending` `SwipeCandidate` that + this user has no `Swipe` row for yet. Card shows poster, title, year, description, and + 👍/👎 buttons. +3. On 👎: create/upsert this user's `Swipe` row (`vote: "down"`), and immediately set the + candidate's `status` to `"dead"`. It disappears from both users' decks (already-fetched + or future). +4. On 👍: create this user's `Swipe` row (`vote: "up"`). + - Query the other user's `Swipe` for this candidate. + - If they also voted `up`: create a `Movie` row via the same creation logic as + `POST /api/movies` (TMDB enrichment fields already known from the candidate, no + re-fetch needed), set `matchedViaSwipe: true`, set candidate `status: "matched"`, + and kick off `syncMovieProviders` exactly as the existing endpoint does. + - Otherwise: leave candidate `status: "pending"`, just move on. +5. Advance to the next card. If none remain after a refill attempt, show an empty-state + ("You're all caught up — check back later"). Note this is not terminal for the + session: since refill is attempted on every `next` request while the pending count is + below threshold, the very next card request (e.g. the user reopening the tab, or a + later visit) retries the fetch. There's no separate "wake up and retry" mechanism — + retry only happens on demand, driven by the user asking for a card. + +### Concurrency & consistency + +Two independent users can act on the same candidate at nearly the same moment, which is +the one place in this feature where two actors write to shared state. This needs +explicit handling, not just the happy path above: + +- **Race on the up/up transition.** Steps in 4 above (upsert this user's swipe → read + the other user's swipe → conditionally create `Movie`) must run inside a single + `prisma.$transaction`, so a near-simultaneous double 👍 can't both pass the "other user + already voted up" check and both attempt to create the `Movie`. As a second line of + defense, `Movie.tmdbId` is already `@unique` (existing schema) — a create that races + past the transaction boundary hits that constraint; catch the conflict and treat it as + "already matched" rather than a 500. +- **Server-side re-validation of candidate status at swipe time.** A client can have a + card loaded that has since gone `dead` (other user downvoted) or `matched` + (concurrent match) server-side. `POST /api/match-night/swipe` must re-check + `candidate.status === "pending"` inside the same transaction before recording the + vote. If it's no longer pending, silently no-op the vote (don't record it, don't + error) and respond as if advancing to the next card — consistent with this feature + having no toast/error UX per the non-goals. + +## Watchlist Decorator + +`MovieRow` / `MovieCard` (`src/components/movie-row.tsx`, `movie-card.tsx`) check +`movie.matchedViaSwipe` and render a small "It's a match! 🎉" badge alongside the existing +card content, styled consistently with the warm amber/cream theme. + +## API Surface (new) + +- `GET /api/match-night/next?user=` — returns the next card for that user (triggers + refill if needed), or `null` if the deck is genuinely exhausted even after a refill + attempt. +- `POST /api/match-night/swipe` — body `{ candidateId, user, vote }`. Performs the vote + recording, dead/match transition, and (on match) watchlist insertion + streaming sync, + all inside a single `prisma.$transaction` per the Concurrency & Consistency section + above. No-ops (no vote recorded, no error) if the candidate is no longer `pending`. + +## Testing + +Vitest, following existing patterns (mocked `fetch` for TMDB/Criterion HTTP calls, mocked +Prisma client): + +- Candidate fetch/dedup: verifies existing `Movie` and `SwipeCandidate` rows are excluded + from newly inserted candidates. +- Swipe transitions: down → dead; up (first voter) → still pending; up (second voter, + matching an existing up) → `Movie` created with `matchedViaSwipe: true`, candidate → + matched. +- Refill trigger: deck below threshold triggers a fetch; deck above threshold does not. +- Refill partial failure: Criterion source throwing/timing out still yields the TMDB + batch rather than aborting the refill. +- Concurrency: two near-simultaneous 👍 swipes on the same candidate (from different + users) result in exactly one `Movie` row and the candidate ending as `matched`, not a + crash or duplicate. +- Stale swipe: a swipe submitted against a candidate that's already `dead` or `matched` + server-side is a no-op — no `Swipe` row created, no error thrown. +- API route tests for `GET /api/match-night/next` and `POST /api/match-night/swipe` + mirroring the style of existing `src/app/api/movies/route.ts` tests. + +## Migration Note + +`matchedViaSwipe Boolean @default(false)` on `Movie` backfills existing rows to `false` +automatically via the column default — no manual data migration needed. diff --git a/package-lock.json b/package-lock.json index 8049dca..8ce1bc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -175,7 +175,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -711,7 +710,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -760,7 +758,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -912,7 +909,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -968,8 +964,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { "version": "0.1.1", @@ -1019,7 +1014,17 @@ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "devOptional": true, "license": "MIT", - "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, "dependencies": { "tslib": "^2.4.0" } @@ -2485,7 +2490,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -3711,6 +3715,29 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/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, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/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, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -4153,6 +4180,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -4242,7 +4270,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", @@ -4315,7 +4344,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4326,7 +4354,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4827,7 +4854,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4907,6 +4933,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -5401,7 +5428,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -6148,6 +6174,7 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -6200,7 +6227,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dotenv": { "version": "17.4.2", @@ -6604,7 +6632,6 @@ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6738,7 +6765,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6977,7 +7003,6 @@ "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", @@ -7437,7 +7462,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -8203,7 +8227,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -9021,7 +9044,6 @@ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", @@ -9586,6 +9608,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10555,7 +10578,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", @@ -10640,6 +10662,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10655,6 +10678,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -10667,7 +10691,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/pretty-ms": { "version": "9.3.0", @@ -10690,7 +10715,6 @@ "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "7.8.0", "@prisma/dev": "0.24.3", @@ -10916,7 +10940,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10926,7 +10949,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -12277,7 +12299,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12415,7 +12436,6 @@ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -12561,7 +12581,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12797,7 +12816,6 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -13272,7 +13290,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/prisma/migrations/20260723023828_add_match_night/migration.sql b/prisma/migrations/20260723023828_add_match_night/migration.sql new file mode 100644 index 0000000..35abe6c --- /dev/null +++ b/prisma/migrations/20260723023828_add_match_night/migration.sql @@ -0,0 +1,63 @@ +-- CreateTable +CREATE TABLE "SwipeCandidate" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "tmdbId" INTEGER NOT NULL, + "imdbId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "year" INTEGER NOT NULL, + "runtime" INTEGER NOT NULL, + "description" TEXT NOT NULL, + "posterUrl" TEXT NOT NULL, + "source" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "Swipe" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "candidateId" INTEGER NOT NULL, + "user" TEXT NOT NULL, + "vote" TEXT NOT NULL, + "swipedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Swipe_candidateId_fkey" FOREIGN KEY ("candidateId") REFERENCES "SwipeCandidate" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Movie" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL, + "year" INTEGER NOT NULL, + "runtime" INTEGER NOT NULL, + "description" TEXT NOT NULL, + "posterUrl" TEXT NOT NULL, + "imdbId" TEXT NOT NULL, + "tmdbId" INTEGER NOT NULL, + "criterionUrl" TEXT, + "imdbUrl" TEXT, + "sortOrder" INTEGER NOT NULL, + "status" TEXT NOT NULL DEFAULT 'watchlist', + "seerrRequestId" TEXT, + "seerrMediaId" TEXT, + "seerrStatus" TEXT NOT NULL DEFAULT 'not_requested', + "watchedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "streamingLastChecked" DATETIME, + "streamingLink" TEXT, + "matchedViaSwipe" BOOLEAN NOT NULL DEFAULT false +); +INSERT INTO "new_Movie" ("createdAt", "criterionUrl", "description", "id", "imdbId", "imdbUrl", "posterUrl", "runtime", "seerrMediaId", "seerrRequestId", "seerrStatus", "sortOrder", "status", "streamingLastChecked", "streamingLink", "title", "tmdbId", "watchedAt", "year") SELECT "createdAt", "criterionUrl", "description", "id", "imdbId", "imdbUrl", "posterUrl", "runtime", "seerrMediaId", "seerrRequestId", "seerrStatus", "sortOrder", "status", "streamingLastChecked", "streamingLink", "title", "tmdbId", "watchedAt", "year" FROM "Movie"; +DROP TABLE "Movie"; +ALTER TABLE "new_Movie" RENAME TO "Movie"; +CREATE UNIQUE INDEX "Movie_imdbId_key" ON "Movie"("imdbId"); +CREATE UNIQUE INDEX "Movie_tmdbId_key" ON "Movie"("tmdbId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE UNIQUE INDEX "SwipeCandidate_tmdbId_key" ON "SwipeCandidate"("tmdbId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Swipe_candidateId_user_key" ON "Swipe"("candidateId", "user"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7ef87d5..6f573d9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,7 @@ model Movie { createdAt DateTime @default(now()) streamingLastChecked DateTime? streamingLink String? + matchedViaSwipe Boolean @default(false) ratings Rating[] streamingProviders StreamingProvider[] } @@ -41,6 +42,32 @@ model StreamingProvider { @@unique([movieId, providerId]) } +model SwipeCandidate { + id Int @id @default(autoincrement()) + tmdbId Int @unique + imdbId String + title String + year Int + runtime Int + description String + posterUrl String + source String // "criterion" | "tmdb" + status String @default("pending") // pending | dead | matched + createdAt DateTime @default(now()) + swipes Swipe[] +} + +model Swipe { + id Int @id @default(autoincrement()) + candidateId Int + user String + vote String // "up" | "down" + swipedAt DateTime @default(now()) + candidate SwipeCandidate @relation(fields: [candidateId], references: [id], onDelete: Cascade) + + @@unique([candidateId, user]) +} + model Rating { id Int @id @default(autoincrement()) movieId Int diff --git a/src/app/api/match-night/next/route.ts b/src/app/api/match-night/next/route.ts new file mode 100644 index 0000000..07d081e --- /dev/null +++ b/src/app/api/match-night/next/route.ts @@ -0,0 +1,18 @@ +// src/app/api/match-night/next/route.ts +import { NextResponse } from 'next/server' +import { getNextCandidateForUser } from '@/lib/match-night' +import { USER_KEYS } from '@/lib/user-utils' +import type { User } from '@/types' + +export const dynamic = 'force-dynamic' + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url) + const user = searchParams.get('user') + if (!USER_KEYS.includes(user as User)) { + return NextResponse.json({ error: 'invalid user' }, { status: 422 }) + } + + const candidate = await getNextCandidateForUser(user as User) + return NextResponse.json({ candidate }) +} diff --git a/src/app/api/match-night/swipe/route.ts b/src/app/api/match-night/swipe/route.ts new file mode 100644 index 0000000..5325a02 --- /dev/null +++ b/src/app/api/match-night/swipe/route.ts @@ -0,0 +1,25 @@ +// src/app/api/match-night/swipe/route.ts +import { NextResponse } from 'next/server' +import { recordSwipe } from '@/lib/match-night' +import { USER_KEYS } from '@/lib/user-utils' +import type { SwipeVote, User } from '@/types' + +interface SwipeBody { + candidateId?: number + user?: User + vote?: SwipeVote +} + +export async function POST(req: Request) { + const body = (await req.json().catch(() => ({}))) as SwipeBody + + if (!body.candidateId || !USER_KEYS.includes(body.user as User)) { + return NextResponse.json({ error: 'invalid request' }, { status: 422 }) + } + if (body.vote !== 'up' && body.vote !== 'down') { + return NextResponse.json({ error: 'vote must be "up" or "down"' }, { status: 422 }) + } + + const result = await recordSwipe(body.candidateId, body.user as User, body.vote) + return NextResponse.json(result) +} diff --git a/src/app/match-night/page.tsx b/src/app/match-night/page.tsx new file mode 100644 index 0000000..8a110fc --- /dev/null +++ b/src/app/match-night/page.tsx @@ -0,0 +1,92 @@ +// src/app/match-night/page.tsx +'use client' +import { useState, useEffect, useCallback } from 'react' +import { MatchNightCard } from '@/components/match-night-card' +import { Button } from '@/components/ui/button' +import { USER_KEYS } from '@/lib/user-utils' +import type { SwipeCandidateRecord, SwipeVote, User } from '@/types' + +export default function MatchNightPage() { + const [userNames, setUserNames] = useState>({ user1: 'User 1', user2: 'User 2' }) + const [currentUser, setCurrentUser] = useState(null) + const [candidate, setCandidate] = useState(null) + const [loading, setLoading] = useState(false) + const [voting, setVoting] = useState(false) + + useEffect(() => { + fetch('/api/user-names') + .then((r) => r.json()) + .then(setUserNames) + .catch(() => {}) + }, []) + + const loadNext = useCallback(async (user: User) => { + setLoading(true) + try { + const res = await fetch(`/api/match-night/next?user=${user}`) + const data = await res.json() + setCandidate(data.candidate) + } finally { + setLoading(false) + } + }, []) + + const handleSelectUser = (user: User) => { + setCurrentUser(user) + loadNext(user) + } + + const handleVote = async (vote: SwipeVote) => { + if (!currentUser || !candidate) return + setVoting(true) + try { + await fetch('/api/match-night/swipe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ candidateId: candidate.id, user: currentUser, vote }), + }) + await loadNext(currentUser) + } finally { + setVoting(false) + } + } + + if (!currentUser) { + return ( +
+

Match Night 💕

+

Who's swiping?

+
+ {USER_KEYS.map((user) => ( + + ))} +
+
+ ) + } + + return ( +
+

Match Night 💕

+

Swiping as {userNames[currentUser]}

+ + {loading ? ( +
Loading next film…
+ ) : candidate ? ( + + ) : ( +
+
🎬
+

You're all caught up!

+

Check back later for more films to swipe on.

+
+ )} +
+ ) +} diff --git a/src/components/match-night-card.tsx b/src/components/match-night-card.tsx new file mode 100644 index 0000000..ed11db2 --- /dev/null +++ b/src/components/match-night-card.tsx @@ -0,0 +1,45 @@ +'use client' +import { MoviePoster } from './movie-poster' +import { Button } from '@/components/ui/button' +import type { SwipeCandidateRecord, SwipeVote } from '@/types' + +interface MatchNightCardProps { + candidate: SwipeCandidateRecord + voting: boolean + onVote: (vote: SwipeVote) => void +} + +export function MatchNightCard({ candidate, voting, onVote }: MatchNightCardProps) { + return ( +
+ +
+

{candidate.title}

+

{candidate.year}

+

{candidate.description}

+
+
+ + +
+
+ ) +} diff --git a/src/components/mobile-bottom-nav.tsx b/src/components/mobile-bottom-nav.tsx index 8d79fd4..a568f0c 100644 --- a/src/components/mobile-bottom-nav.tsx +++ b/src/components/mobile-bottom-nav.tsx @@ -7,6 +7,7 @@ const tabs = [ { href: '/watchlist', label: 'List', icon: '📋' }, { href: '/watched', label: 'Watched', icon: '✅' }, { href: '/add', label: 'Add', icon: '➕' }, + { href: '/match-night', label: 'Match', icon: '💕' }, { href: '/recommendations', label: 'Recs', icon: '🎯' }, ] diff --git a/src/components/movie-card.tsx b/src/components/movie-card.tsx index 11294a7..169b2ca 100644 --- a/src/components/movie-card.tsx +++ b/src/components/movie-card.tsx @@ -121,6 +121,11 @@ export function MovieCard({ movie, userNames, seerrUrl }: MovieCardProps) { )}
+ {movie.matchedViaSwipe && ( + + It's a match! 🎉 + + )}
diff --git a/src/components/movie-row.tsx b/src/components/movie-row.tsx index ff18e0d..5c1970c 100644 --- a/src/components/movie-row.tsx +++ b/src/components/movie-row.tsx @@ -104,6 +104,11 @@ export function MovieRow({ {/* Status pills + streaming info live here */}
+ {movie.matchedViaSwipe && ( + + It's a match! 🎉 + + )} {isStreamable && ( Streaming diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index fbf6763..81f1732 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -9,6 +9,7 @@ const navItems = [ { href: '/watchlist', label: 'Watch List', icon: '📋' }, { href: '/watched', label: 'Watched', icon: '✅' }, { href: '/add', label: 'Add Movie', icon: '➕' }, + { href: '/match-night', label: 'Match Night', icon: '💕' }, { href: '/recommendations', label: 'Recommend', icon: '🎯' }, ] diff --git a/src/lib/criterion-catalog.ts b/src/lib/criterion-catalog.ts new file mode 100644 index 0000000..0789ff0 --- /dev/null +++ b/src/lib/criterion-catalog.ts @@ -0,0 +1,10 @@ +import catalog from '../../data/criterion-catalog.json' + +export interface CriterionCatalogEntry { + title: string + year?: number +} + +export function getCriterionCatalog(): CriterionCatalogEntry[] { + return catalog as CriterionCatalogEntry[] +} diff --git a/src/lib/match-night.ts b/src/lib/match-night.ts new file mode 100644 index 0000000..d2e3627 --- /dev/null +++ b/src/lib/match-night.ts @@ -0,0 +1,181 @@ +// src/lib/match-night.ts +import { prisma } from './db' +import { getCriterionCatalog } from './criterion-catalog' +import { searchByTitle, fetchPopularMovies } from './tmdb' +import { Prisma } from '@prisma/client' +import { otherUser } from './user-utils' +import { syncMovieProviders } from './streaming' +import type { TmdbMovieDetails, Movie, SwipeCandidateRecord, SwipeVote, User } from '@/types' + +export const REFILL_THRESHOLD = 5 +export const REFILL_BATCH_SIZE = 20 + +const CRITERION_CURSOR_KEY = 'match_night_criterion_cursor' +const TMDB_PAGE_CURSOR_KEY = 'match_night_tmdb_popular_page' +const MAX_TMDB_PAGES_PER_REFILL = 5 + +interface NewCandidate extends TmdbMovieDetails { + source: 'criterion' | 'tmdb' +} + +function shuffle(items: T[]): T[] { + const result = [...items] + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[result[i], result[j]] = [result[j], result[i]] + } + return result +} + +async function getCursor(key: string): Promise { + const row = await prisma.setting.findUnique({ where: { key } }) + return row ? parseInt(row.value, 10) || 0 : 0 +} + +async function setCursor(key: string, value: number): Promise { + await prisma.setting.upsert({ + where: { key }, + create: { key, value: String(value) }, + update: { value: String(value) }, + }) +} + +async function getExistingTmdbIds(): Promise> { + const [movies, candidates] = await Promise.all([ + prisma.movie.findMany({ select: { tmdbId: true } }), + prisma.swipeCandidate.findMany({ select: { tmdbId: true } }), + ]) + return new Set([ + ...movies.map((m) => m.tmdbId), + ...candidates.map((c) => c.tmdbId), + ]) +} + +export async function refillCandidates(): Promise { + const existingTmdbIds = await getExistingTmdbIds() + const toInsert: NewCandidate[] = [] + const halfBatch = REFILL_BATCH_SIZE / 2 + + // Criterion: resolve forward from the saved cursor through the static catalog + const catalog = getCriterionCatalog() + let criterionCursor = await getCursor(CRITERION_CURSOR_KEY) + const criterionCollected = () => toInsert.filter((c) => c.source === 'criterion').length + + while (criterionCursor < catalog.length && criterionCollected() < halfBatch) { + const entry = catalog[criterionCursor] + criterionCursor++ + const details = await searchByTitle(entry.title, entry.year).catch(() => null) + if (details && details.imdbId !== '' && !existingTmdbIds.has(details.tmdbId)) { + existingTmdbIds.add(details.tmdbId) + toInsert.push({ ...details, source: 'criterion' }) + } + } + await setCursor(CRITERION_CURSOR_KEY, criterionCursor) + + // TMDB popular: page forward from the saved cursor + let page = (await getCursor(TMDB_PAGE_CURSOR_KEY)) || 1 + let pagesFetched = 0 + + while (toInsert.length < REFILL_BATCH_SIZE && pagesFetched < MAX_TMDB_PAGES_PER_REFILL) { + const results = await fetchPopularMovies(page).catch(() => []) + pagesFetched++ + page++ + for (const details of results) { + if (details.imdbId === '' || existingTmdbIds.has(details.tmdbId)) continue + existingTmdbIds.add(details.tmdbId) + toInsert.push({ ...details, source: 'tmdb' }) + if (toInsert.length >= REFILL_BATCH_SIZE) break + } + } + await setCursor(TMDB_PAGE_CURSOR_KEY, page) + + if (toInsert.length > 0) { + await prisma.swipeCandidate.createMany({ data: shuffle(toInsert) }) + } + return toInsert.length +} + +export async function getNextCandidateForUser(user: User): Promise { + const pendingCount = await prisma.swipeCandidate.count({ + where: { status: 'pending', swipes: { none: { user } } }, + }) + if (pendingCount < REFILL_THRESHOLD) { + await refillCandidates() + } + + return (await findNextPending(user)) as unknown as SwipeCandidateRecord | null +} + +function findNextPending(user: User) { + return prisma.swipeCandidate.findFirst({ + where: { status: 'pending', swipes: { none: { user } } }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + }) +} + +export type SwipeResult = + | { status: 'recorded' } + | { status: 'matched'; movie: Movie } + | { status: 'ignored' } + +export async function recordSwipe( + candidateId: number, + user: User, + vote: SwipeVote +): Promise { + return prisma.$transaction(async (tx): Promise => { + const candidate = await tx.swipeCandidate.findUnique({ where: { id: candidateId } }) + if (!candidate || candidate.status !== 'pending') { + return { status: 'ignored' } + } + + await tx.swipe.create({ data: { candidateId, user, vote } }) + + if (vote === 'down') { + await tx.swipeCandidate.update({ where: { id: candidateId }, data: { status: 'dead' } }) + return { status: 'recorded' } + } + + const other = otherUser(user) + const otherSwipe = await tx.swipe.findUnique({ + where: { candidateId_user: { candidateId, user: other } }, + }) + if (!otherSwipe || otherSwipe.vote !== 'up') { + return { status: 'recorded' } + } + + const { _max } = await tx.movie.aggregate({ _max: { sortOrder: true } }) + let movie + try { + movie = await tx.movie.create({ + data: { + title: candidate.title, + year: candidate.year, + runtime: candidate.runtime, + description: candidate.description, + posterUrl: candidate.posterUrl, + imdbId: candidate.imdbId, + tmdbId: candidate.tmdbId, + sortOrder: (_max.sortOrder ?? 0) + 1, + matchedViaSwipe: true, + }, + }) + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + movie = await tx.movie.findUniqueOrThrow({ where: { tmdbId: candidate.tmdbId } }) + } else { + throw err + } + } + + await tx.swipeCandidate.update({ where: { id: candidateId }, data: { status: 'matched' } }) + return { status: 'matched', movie: movie as unknown as Movie } + }).then((result: SwipeResult) => { + if (result.status === 'matched') { + syncMovieProviders(result.movie.id, result.movie.tmdbId).catch((err) => + console.error('[match-night] Failed to sync providers for matched movie:', err) + ) + } + return result + }) +} diff --git a/src/lib/tmdb.ts b/src/lib/tmdb.ts index 931302f..4b1d584 100644 --- a/src/lib/tmdb.ts +++ b/src/lib/tmdb.ts @@ -4,9 +4,14 @@ import { getConfig } from './config' const BASE = 'https://api.themoviedb.org/3' const IMG_BASE = 'https://image.tmdb.org/t/p/w500' +const FETCH_TIMEOUT_MS = 5000 + +function fetchWithTimeout(url: string): Promise { + return fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) +} async function fetchDetails(tmdbId: number, key: string): Promise { - const res = await fetch(`${BASE}/movie/${tmdbId}?api_key=${key}`) + const res = await fetchWithTimeout(`${BASE}/movie/${tmdbId}?api_key=${key}`) if (!res.ok) return null const m = await res.json() return { @@ -40,7 +45,7 @@ export async function searchByTitle( ): Promise { const { tmdbApiKey } = await getConfig() const yearParam = year ? `&year=${year}` : '' - const res = await fetch( + const res = await fetchWithTimeout( `${BASE}/search/movie?api_key=${tmdbApiKey}&query=${encodeURIComponent(title)}${yearParam}` ) if (!res.ok) return null @@ -124,3 +129,13 @@ export async function fetchProviderList(region: string): Promise { + const { tmdbApiKey } = await getConfig() + const res = await fetchWithTimeout(`${BASE}/movie/popular?api_key=${tmdbApiKey}&page=${page}`) + if (!res.ok) return [] + const data = await res.json() + const hits = (data.results ?? []) as Array<{ id: number }> + const details = await Promise.all(hits.map((h) => fetchDetails(h.id, tmdbApiKey))) + return details.filter((d): d is TmdbMovieDetails => d !== null) +} diff --git a/src/types/index.ts b/src/types/index.ts index 2325b15..a64bfd1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,6 +13,10 @@ export type User = 'user1' | 'user2' export type RatingValue = 'up' | 'down' +export type SwipeSource = 'criterion' | 'tmdb' +export type SwipeStatus = 'pending' | 'dead' | 'matched' +export type SwipeVote = 'up' | 'down' + export interface StreamingProvider { id: number movieId: number @@ -40,6 +44,7 @@ export interface Movie { createdAt: Date | string streamingLastChecked?: Date | string | null streamingLink?: string | null + matchedViaSwipe: boolean ratings?: Rating[] streamingProviders?: StreamingProvider[] } @@ -53,6 +58,28 @@ export interface Rating { submittedAt: Date | string } +export interface SwipeCandidateRecord { + id: number + tmdbId: number + imdbId: string + title: string + year: number + runtime: number + description: string + posterUrl: string + source: SwipeSource + status: SwipeStatus + createdAt: Date | string +} + +export interface Swipe { + id: number + candidateId: number + user: User + vote: SwipeVote + swipedAt: Date | string +} + export interface TmdbMovieDetails { tmdbId: number title: string diff --git a/tests/api.match-night.test.ts b/tests/api.match-night.test.ts new file mode 100644 index 0000000..74c197a --- /dev/null +++ b/tests/api.match-night.test.ts @@ -0,0 +1,87 @@ +// tests/api.match-night.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/match-night', () => ({ + getNextCandidateForUser: vi.fn(), + recordSwipe: vi.fn(), +})) + +import { getNextCandidateForUser, recordSwipe } from '@/lib/match-night' +import { GET } from '@/app/api/match-night/next/route' +import { POST } from '@/app/api/match-night/swipe/route' + +describe('GET /api/match-night/next', () => { + beforeEach(() => vi.clearAllMocks()) + + it('returns the next candidate for a valid user', async () => { + vi.mocked(getNextCandidateForUser).mockResolvedValue({ id: 1, title: 'Seven Samurai' } as any) + const req = new Request('http://localhost/api/match-night/next?user=user1') + const res = await GET(req) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ candidate: { id: 1, title: 'Seven Samurai' } }) + expect(getNextCandidateForUser).toHaveBeenCalledWith('user1') + }) + + it('returns candidate: null when the deck is empty', async () => { + vi.mocked(getNextCandidateForUser).mockResolvedValue(null) + const req = new Request('http://localhost/api/match-night/next?user=user2') + const res = await GET(req) + expect(await res.json()).toEqual({ candidate: null }) + }) + + it('returns 422 for a missing/invalid user', async () => { + const req = new Request('http://localhost/api/match-night/next?user=nobody') + const res = await GET(req) + expect(res.status).toBe(422) + }) +}) + +describe('POST /api/match-night/swipe', () => { + beforeEach(() => vi.clearAllMocks()) + + it('records a valid swipe', async () => { + vi.mocked(recordSwipe).mockResolvedValue({ status: 'recorded' }) + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ candidateId: 1, user: 'user1', vote: 'up' }), + }) + const res = await POST(req) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ status: 'recorded' }) + expect(recordSwipe).toHaveBeenCalledWith(1, 'user1', 'up') + }) + + it('returns the matched movie payload on a match', async () => { + vi.mocked(recordSwipe).mockResolvedValue({ status: 'matched', movie: { id: 10 } as any }) + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ candidateId: 1, user: 'user2', vote: 'up' }), + }) + const res = await POST(req) + expect(await res.json()).toEqual({ status: 'matched', movie: { id: 10 } }) + }) + + it('returns 422 for an invalid user', async () => { + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ candidateId: 1, user: 'nobody', vote: 'up' }), + }) + expect((await POST(req)).status).toBe(422) + }) + + it('returns 422 for a missing candidateId', async () => { + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ user: 'user1', vote: 'up' }), + }) + expect((await POST(req)).status).toBe(422) + }) + + it('returns 422 for an invalid vote value', async () => { + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ candidateId: 1, user: 'user1', vote: 'sideways' }), + }) + expect((await POST(req)).status).toBe(422) + }) +}) diff --git a/tests/criterion-catalog.test.ts b/tests/criterion-catalog.test.ts new file mode 100644 index 0000000..b37c33c --- /dev/null +++ b/tests/criterion-catalog.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { getCriterionCatalog } from '@/lib/criterion-catalog' + +describe('getCriterionCatalog', () => { + it('returns a non-empty array of title entries', () => { + const catalog = getCriterionCatalog() + expect(Array.isArray(catalog)).toBe(true) + expect(catalog.length).toBeGreaterThan(10) + }) + + it('every entry has a non-empty string title', () => { + const catalog = getCriterionCatalog() + for (const entry of catalog) { + expect(typeof entry.title).toBe('string') + expect(entry.title.length).toBeGreaterThan(0) + } + }) + + it('every entry with a year has a plausible film year', () => { + const catalog = getCriterionCatalog() + for (const entry of catalog) { + if (entry.year !== undefined) { + expect(entry.year).toBeGreaterThan(1880) + expect(entry.year).toBeLessThanOrEqual(new Date().getFullYear()) + } + } + }) +}) diff --git a/tests/match-night-card.test.tsx b/tests/match-night-card.test.tsx new file mode 100644 index 0000000..ef7eba1 --- /dev/null +++ b/tests/match-night-card.test.tsx @@ -0,0 +1,43 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { MatchNightCard } from '@/components/match-night-card' +import type { SwipeCandidateRecord } from '@/types' + +vi.mock('next/image', () => ({ + default: ({ src, alt }: { src: string; alt: string }) => {alt}, +})) + +const candidate: SwipeCandidateRecord = { + id: 1, tmdbId: 345911, imdbId: 'tt0047478', title: 'Seven Samurai', year: 1954, + runtime: 207, description: 'A poor village recruits seven samurai.', posterUrl: 'poster.jpg', + source: 'criterion', status: 'pending', createdAt: new Date().toISOString(), +} + +describe('MatchNightCard', () => { + it('renders the title, year, and description', () => { + render() + expect(screen.getByText('Seven Samurai')).toBeInTheDocument() + expect(screen.getByText('1954')).toBeInTheDocument() + expect(screen.getByText(/seven samurai\.$/i)).toBeInTheDocument() + }) + + it('calls onVote("up") when the thumbs-up button is clicked', () => { + const onVote = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /thumbs up/i })) + expect(onVote).toHaveBeenCalledWith('up') + }) + + it('calls onVote("down") when the thumbs-down button is clicked', () => { + const onVote = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /thumbs down/i })) + expect(onVote).toHaveBeenCalledWith('down') + }) + + it('disables both buttons while voting', () => { + render() + expect(screen.getByRole('button', { name: /thumbs up/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /thumbs down/i })).toBeDisabled() + }) +}) diff --git a/tests/match-night-page.test.tsx b/tests/match-night-page.test.tsx new file mode 100644 index 0000000..863cf4b --- /dev/null +++ b/tests/match-night-page.test.tsx @@ -0,0 +1,93 @@ +// tests/match-night-page.test.tsx +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import MatchNightPage from '@/app/match-night/page' + +vi.mock('next/image', () => ({ + default: ({ src, alt }: { src: string; alt: string }) => {alt}, +})) + +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +const candidate = { + id: 1, tmdbId: 345911, imdbId: 'tt0047478', title: 'Seven Samurai', year: 1954, + runtime: 207, description: 'desc', posterUrl: 'poster.jpg', source: 'criterion', + status: 'pending', createdAt: new Date().toISOString(), +} + +function jsonResponse(data: unknown) { + return Promise.resolve({ ok: true, json: async () => data }) +} + +describe('MatchNightPage', () => { + beforeEach(() => { + mockFetch.mockReset() + mockFetch.mockImplementation((url: string) => { + if (url.includes('/api/user-names')) return jsonResponse({ user1: 'Ian', user2: 'Krista' }) + if (url.includes('/api/match-night/next')) return jsonResponse({ candidate }) + if (url.includes('/api/match-night/swipe')) return jsonResponse({ status: 'recorded' }) + return jsonResponse({}) + }) + }) + + it('shows a user picker before swiping starts', async () => { + render() + await waitFor(() => expect(screen.getByText('Ian')).toBeInTheDocument()) + expect(screen.getByText('Krista')).toBeInTheDocument() + }) + + it('loads the next candidate after picking a user', async () => { + render() + await waitFor(() => screen.getByText('Ian')) + fireEvent.click(screen.getByText('Ian')) + await waitFor(() => expect(screen.getByText('Seven Samurai')).toBeInTheDocument()) + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/match-night/next?user=user1')) + }) + + it('submits a swipe and requests the next card', async () => { + render() + await waitFor(() => screen.getByText('Ian')) + fireEvent.click(screen.getByText('Ian')) + await waitFor(() => screen.getByText('Seven Samurai')) + + fireEvent.click(screen.getByRole('button', { name: /thumbs up/i })) + + await waitFor(() => + expect(mockFetch).toHaveBeenCalledWith( + '/api/match-night/swipe', + expect.objectContaining({ method: 'POST' }) + ) + ) + }) + + it('requests the next card after a match, without showing any in-page match feedback', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.includes('/api/user-names')) return jsonResponse({ user1: 'Ian', user2: 'Krista' }) + if (url.includes('/api/match-night/next')) return jsonResponse({ candidate }) + if (url.includes('/api/match-night/swipe')) return jsonResponse({ status: 'matched', movie: { id: 10 } }) + return jsonResponse({}) + }) + render() + await waitFor(() => screen.getByText('Ian')) + fireEvent.click(screen.getByText('Ian')) + await waitFor(() => screen.getByText('Seven Samurai')) + fireEvent.click(screen.getByRole('button', { name: /thumbs up/i })) + await waitFor(() => + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/match-night/next?user=user1')) + ) + expect(screen.queryByText(/it's a match/i)).not.toBeInTheDocument() + }) + + it('shows an empty state when there is no next candidate', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.includes('/api/user-names')) return jsonResponse({ user1: 'Ian', user2: 'Krista' }) + if (url.includes('/api/match-night/next')) return jsonResponse({ candidate: null }) + return jsonResponse({}) + }) + render() + await waitFor(() => screen.getByText('Ian')) + fireEvent.click(screen.getByText('Ian')) + await waitFor(() => expect(screen.getByText(/all caught up/i)).toBeInTheDocument()) + }) +}) diff --git a/tests/match-night.test.ts b/tests/match-night.test.ts new file mode 100644 index 0000000..6f7ad3a --- /dev/null +++ b/tests/match-night.test.ts @@ -0,0 +1,306 @@ +// tests/match-night.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/db', () => { + const prisma: any = { + movie: { findMany: vi.fn(), aggregate: vi.fn(), create: vi.fn(), findUnique: vi.fn(), findUniqueOrThrow: vi.fn() }, + swipeCandidate: { + findMany: vi.fn(), createMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn(), update: vi.fn(), count: vi.fn(), + }, + swipe: { create: vi.fn(), findUnique: vi.fn() }, + setting: { findUnique: vi.fn(), upsert: vi.fn() }, + } + prisma.$transaction = vi.fn((cb: any) => cb(prisma)) + return { prisma } +}) +vi.mock('@/lib/criterion-catalog', () => ({ + getCriterionCatalog: vi.fn(), +})) +vi.mock('@/lib/tmdb', () => ({ + searchByTitle: vi.fn(), + fetchPopularMovies: vi.fn(), +})) +vi.mock('@/lib/streaming', () => ({ syncMovieProviders: vi.fn().mockResolvedValue(undefined) })) + +import { prisma } from '@/lib/db' +import { getCriterionCatalog } from '@/lib/criterion-catalog' +import { searchByTitle, fetchPopularMovies } from '@/lib/tmdb' +import { refillCandidates, getNextCandidateForUser, recordSwipe } from '@/lib/match-night' + +const tmdbDetails = (overrides: Partial = {}) => ({ + tmdbId: 1, title: 'Some Film', year: 1960, runtime: 90, + description: 'desc', posterUrl: 'poster.jpg', imdbId: 'tt0000001', + ...overrides, +}) + +describe('refillCandidates', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(prisma.movie.findMany).mockResolvedValue([]) + vi.mocked(prisma.swipeCandidate.findMany).mockResolvedValue([]) + vi.mocked(prisma.setting.findUnique).mockResolvedValue(null) + vi.mocked(prisma.setting.upsert).mockResolvedValue({} as any) + vi.mocked(prisma.swipeCandidate.createMany).mockResolvedValue({ count: 0 } as any) + }) + + it('resolves catalog entries via TMDB and inserts them as criterion candidates', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([{ title: 'Seven Samurai', year: 1954 }]) + vi.mocked(searchByTitle).mockResolvedValue(tmdbDetails({ tmdbId: 345911, title: 'Seven Samurai' })) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + + const count = await refillCandidates() + + expect(count).toBe(1) + expect(prisma.swipeCandidate.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ tmdbId: 345911, source: 'criterion' })], + }) + }) + + it('skips a resolved title that already exists as a Movie', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([{ title: 'Seven Samurai', year: 1954 }]) + vi.mocked(prisma.movie.findMany).mockResolvedValue([{ tmdbId: 345911 }] as any) + vi.mocked(searchByTitle).mockResolvedValue(tmdbDetails({ tmdbId: 345911 })) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + + const count = await refillCandidates() + expect(count).toBe(0) + }) + + it('skips a resolved title that already exists as a SwipeCandidate', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([{ title: 'Seven Samurai', year: 1954 }]) + vi.mocked(prisma.swipeCandidate.findMany).mockResolvedValue([{ tmdbId: 345911 }] as any) + vi.mocked(searchByTitle).mockResolvedValue(tmdbDetails({ tmdbId: 345911 })) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + + const count = await refillCandidates() + expect(count).toBe(0) + }) + + it('adds TMDB popular results as tmdb-source candidates', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(fetchPopularMovies).mockResolvedValue([tmdbDetails({ tmdbId: 99 })]) + + const count = await refillCandidates() + expect(count).toBe(1) + expect(prisma.swipeCandidate.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ tmdbId: 99, source: 'tmdb' })], + }) + }) + + it('continues with TMDB-only results when a Criterion title fails to resolve', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([{ title: 'Unresolvable Title' }]) + vi.mocked(searchByTitle).mockRejectedValue(new Error('timeout')) + vi.mocked(fetchPopularMovies).mockResolvedValue([tmdbDetails({ tmdbId: 99 })]) + + const count = await refillCandidates() + expect(count).toBe(1) + expect(prisma.swipeCandidate.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ tmdbId: 99, source: 'tmdb' })], + }) + }) + + it('skips a TMDB result with an empty imdbId', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(fetchPopularMovies).mockResolvedValue([ + tmdbDetails({ tmdbId: 99, imdbId: '' }), + tmdbDetails({ tmdbId: 100, imdbId: 'tt0000100' }), + ]) + + const count = await refillCandidates() + expect(count).toBe(1) + expect(prisma.swipeCandidate.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ tmdbId: 100, source: 'tmdb' })], + }) + }) + + it('interleaves criterion and tmdb candidates instead of blocking them by source', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([ + { title: 'Criterion A' }, { title: 'Criterion B' }, { title: 'Criterion C' }, + { title: 'Criterion D' }, { title: 'Criterion E' }, + ]) + vi.mocked(searchByTitle) + .mockResolvedValueOnce(tmdbDetails({ tmdbId: 1 })) + .mockResolvedValueOnce(tmdbDetails({ tmdbId: 2 })) + .mockResolvedValueOnce(tmdbDetails({ tmdbId: 3 })) + .mockResolvedValueOnce(tmdbDetails({ tmdbId: 4 })) + .mockResolvedValueOnce(tmdbDetails({ tmdbId: 5 })) + vi.mocked(fetchPopularMovies).mockResolvedValue([ + tmdbDetails({ tmdbId: 101 }), tmdbDetails({ tmdbId: 102 }), tmdbDetails({ tmdbId: 103 }), + tmdbDetails({ tmdbId: 104 }), tmdbDetails({ tmdbId: 105 }), + ]) + + // Deterministic Fisher-Yates: forcing every draw to 0 still produces a + // real permutation (not the identity), so this exercises real shuffle + // logic without relying on chance to avoid a flaky assertion. + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + try { + await refillCandidates() + } finally { + randomSpy.mockRestore() + } + + const inserted = vi.mocked(prisma.swipeCandidate.createMany).mock.calls[0][0].data as Array<{ source: string }> + expect(inserted).toHaveLength(10) + // Insertion order shouldn't be strictly "all criterion, then all tmdb" — + // some tmdb candidate must appear before the last criterion candidate. + const lastCriterionIndex = inserted.map((c) => c.source).lastIndexOf('criterion') + const firstTmdbIndex = inserted.map((c) => c.source).indexOf('tmdb') + expect(firstTmdbIndex).toBeLessThan(lastCriterionIndex) + }) + + it('persists the TMDB popular page cursor after a refill', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(prisma.setting.findUnique).mockResolvedValue({ key: 'match_night_tmdb_popular_page', value: '3' } as any) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + + await refillCandidates() + expect(fetchPopularMovies).toHaveBeenCalledWith(3) + expect(prisma.setting.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { key: 'match_night_tmdb_popular_page' } }) + ) + }) +}) + +describe('getNextCandidateForUser', () => { + beforeEach(() => vi.clearAllMocks()) + + it('returns the next candidate without refilling when the pending count is at or above threshold', async () => { + vi.mocked(prisma.swipeCandidate.count).mockResolvedValue(5) + const candidate = { id: 5, status: 'pending' } + vi.mocked(prisma.swipeCandidate.findFirst).mockResolvedValue(candidate as any) + + const result = await getNextCandidateForUser('user1') + + expect(result).toEqual(candidate) + expect(prisma.swipeCandidate.count).toHaveBeenCalledWith({ + where: { status: 'pending', swipes: { none: { user: 'user1' } } }, + }) + expect(prisma.swipeCandidate.findFirst).toHaveBeenCalledWith({ + where: { status: 'pending', swipes: { none: { user: 'user1' } } }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + }) + expect(getCriterionCatalog).not.toHaveBeenCalled() + }) + + it('refills before returning when the pending count is below threshold', async () => { + vi.mocked(prisma.swipeCandidate.count).mockResolvedValue(2) + vi.mocked(prisma.swipeCandidate.findFirst).mockResolvedValue({ id: 7 } as any) + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(fetchPopularMovies).mockResolvedValue([tmdbDetails({ tmdbId: 42 })]) + vi.mocked(prisma.movie.findMany).mockResolvedValue([]) + vi.mocked(prisma.swipeCandidate.findMany).mockResolvedValue([]) + vi.mocked(prisma.setting.findUnique).mockResolvedValue(null) + + const result = await getNextCandidateForUser('user1') + + expect(result).toEqual({ id: 7 }) + expect(getCriterionCatalog).toHaveBeenCalled() + expect(prisma.swipeCandidate.createMany).toHaveBeenCalled() + }) + + it('returns null when the pending count is below threshold and a refill adds nothing', async () => { + vi.mocked(prisma.swipeCandidate.count).mockResolvedValue(0) + vi.mocked(prisma.swipeCandidate.findFirst).mockResolvedValue(null) + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + vi.mocked(prisma.movie.findMany).mockResolvedValue([]) + vi.mocked(prisma.swipeCandidate.findMany).mockResolvedValue([]) + vi.mocked(prisma.setting.findUnique).mockResolvedValue(null) + + const result = await getNextCandidateForUser('user1') + + expect(result).toBeNull() + }) +}) + +describe('recordSwipe', () => { + beforeEach(() => vi.clearAllMocks()) + + const pendingCandidate = { + id: 1, tmdbId: 345911, imdbId: 'tt0047478', title: 'Seven Samurai', + year: 1954, runtime: 207, description: 'desc', posterUrl: 'p.jpg', status: 'pending', + } + + it('marks the candidate dead on a down vote and does not touch Movie', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + + const result = await recordSwipe(1, 'user1', 'down') + + expect(result).toEqual({ status: 'recorded' }) + expect(prisma.swipe.create).toHaveBeenCalledWith({ data: { candidateId: 1, user: 'user1', vote: 'down' } }) + expect(prisma.swipeCandidate.update).toHaveBeenCalledWith({ where: { id: 1 }, data: { status: 'dead' } }) + expect(prisma.movie.create).not.toHaveBeenCalled() + }) + + it('just records the vote when the other user has not voted up yet', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + vi.mocked(prisma.swipe.findUnique).mockResolvedValue(null) + + const result = await recordSwipe(1, 'user1', 'up') + + expect(result).toEqual({ status: 'recorded' }) + expect(prisma.movie.create).not.toHaveBeenCalled() + }) + + it('creates the Movie and marks matched when both users are up', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + vi.mocked(prisma.swipe.findUnique).mockResolvedValue({ candidateId: 1, user: 'user2', vote: 'up' } as any) + vi.mocked(prisma.movie.aggregate).mockResolvedValue({ _max: { sortOrder: 4 } } as any) + const createdMovie = { id: 10, title: 'Seven Samurai', tmdbId: 345911 } + vi.mocked(prisma.movie.create).mockResolvedValue(createdMovie as any) + + const result = await recordSwipe(1, 'user1', 'up') + + expect(result).toEqual({ status: 'matched', movie: createdMovie }) + expect(prisma.movie.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tmdbId: 345911, title: 'Seven Samurai', sortOrder: 5, matchedViaSwipe: true, + }), + }) + expect(prisma.swipeCandidate.update).toHaveBeenCalledWith({ where: { id: 1 }, data: { status: 'matched' } }) + }) + + it('triggers syncMovieProviders after a match', async () => { + const { syncMovieProviders } = await import('@/lib/streaming') + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + vi.mocked(prisma.swipe.findUnique).mockResolvedValue({ vote: 'up' } as any) + vi.mocked(prisma.movie.aggregate).mockResolvedValue({ _max: { sortOrder: 0 } } as any) + vi.mocked(prisma.movie.create).mockResolvedValue({ id: 10, tmdbId: 345911 } as any) + + await recordSwipe(1, 'user1', 'up') + await new Promise((r) => setTimeout(r, 0)) + expect(syncMovieProviders).toHaveBeenCalledWith(10, 345911) + }) + + it('is a no-op when the candidate is no longer pending', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue({ ...pendingCandidate, status: 'dead' } as any) + + const result = await recordSwipe(1, 'user1', 'up') + + expect(result).toEqual({ status: 'ignored' }) + expect(prisma.swipe.create).not.toHaveBeenCalled() + }) + + it('is a no-op when the candidate does not exist', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(null) + + const result = await recordSwipe(999, 'user1', 'up') + expect(result).toEqual({ status: 'ignored' }) + }) + + it('falls back to the existing Movie on a unique constraint race instead of erroring', async () => { + const { Prisma } = await import('@prisma/client') + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + vi.mocked(prisma.swipe.findUnique).mockResolvedValue({ vote: 'up' } as any) + vi.mocked(prisma.movie.aggregate).mockResolvedValue({ _max: { sortOrder: 0 } } as any) + const p2002 = new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { + code: 'P2002', clientVersion: '7.0.0', + }) + vi.mocked(prisma.movie.create).mockRejectedValue(p2002) + const existingMovie = { id: 10, tmdbId: 345911 } + vi.mocked(prisma.movie.findUniqueOrThrow).mockResolvedValue(existingMovie as any) + + const result = await recordSwipe(1, 'user1', 'up') + expect(result).toEqual({ status: 'matched', movie: existingMovie }) + }) +}) diff --git a/tests/mobile-bottom-nav.test.tsx b/tests/mobile-bottom-nav.test.tsx index ba7747f..40a3bfe 100644 --- a/tests/mobile-bottom-nav.test.tsx +++ b/tests/mobile-bottom-nav.test.tsx @@ -9,12 +9,13 @@ vi.mock('next/navigation', () => ({ import { MobileBottomNav } from '@/components/mobile-bottom-nav' describe('MobileBottomNav', () => { - it('renders all four navigation tabs', () => { + it('renders all five navigation tabs', () => { render() expect(screen.getByText('List')).toBeInTheDocument() expect(screen.getByText('Watched')).toBeInTheDocument() expect(screen.getByText('Add')).toBeInTheDocument() expect(screen.getByText('Recs')).toBeInTheDocument() + expect(screen.getByText('Match')).toBeInTheDocument() }) it('links to the correct routes', () => { @@ -23,6 +24,7 @@ describe('MobileBottomNav', () => { expect(screen.getByRole('link', { name: /watched/i })).toHaveAttribute('href', '/watched') expect(screen.getByRole('link', { name: /add/i })).toHaveAttribute('href', '/add') expect(screen.getByRole('link', { name: /recs/i })).toHaveAttribute('href', '/recommendations') + expect(screen.getByRole('link', { name: /match/i })).toHaveAttribute('href', '/match-night') }) it('does not include Settings in the bottom nav tabs', () => { diff --git a/tests/movie-card.test.tsx b/tests/movie-card.test.tsx index b84c4c8..c9ef29e 100644 --- a/tests/movie-card.test.tsx +++ b/tests/movie-card.test.tsx @@ -109,3 +109,17 @@ describe('MovieCard cleanup button', () => { await waitFor(() => expect(screen.getByText("Alice's verdict")).toBeInTheDocument()) }) }) + +describe('MovieCard match badge', () => { + beforeEach(() => mockFetch.mockReset()) + + it('shows the match badge when matchedViaSwipe is true', () => { + render() + expect(screen.getByText(/it's a match/i)).toBeInTheDocument() + }) + + it('does not show the match badge for regularly-added movies', () => { + render() + expect(screen.queryByText(/it's a match/i)).not.toBeInTheDocument() + }) +}) diff --git a/tests/movie-row.test.tsx b/tests/movie-row.test.tsx index 91f245e..9ea3e5e 100644 --- a/tests/movie-row.test.tsx +++ b/tests/movie-row.test.tsx @@ -32,6 +32,7 @@ function makeMovie(overrides: Partial = {}): Movie { createdAt: new Date().toISOString(), streamingLastChecked: new Date().toISOString(), streamingLink: null, + matchedViaSwipe: false, ratings: [], streamingProviders: [], ...overrides, @@ -116,3 +117,17 @@ describe('MovieRow layout', () => { expect(watchLink).toHaveClass('border-amber-400') }) }) + +describe('MovieRow match badge', () => { + beforeEach(() => mockFetch.mockReset()) + + it('shows the match badge when matchedViaSwipe is true', () => { + render() + expect(screen.getByText(/it's a match/i)).toBeInTheDocument() + }) + + it('does not show the match badge for regularly-added movies', () => { + render() + expect(screen.queryByText(/it's a match/i)).not.toBeInTheDocument() + }) +}) diff --git a/tests/sidebar.test.tsx b/tests/sidebar.test.tsx index 6165594..305daae 100644 --- a/tests/sidebar.test.tsx +++ b/tests/sidebar.test.tsx @@ -20,6 +20,7 @@ describe('Sidebar', () => { expect(screen.getByRole('link', { name: /watched/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /add movie/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /recommend/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /match night/i })).toBeInTheDocument() }) it('does not render Browse Criterion or Browse IMDB in the utility footer', () => { diff --git a/tests/tmdb.test.ts b/tests/tmdb.test.ts index 5647457..e0ad1d6 100644 --- a/tests/tmdb.test.ts +++ b/tests/tmdb.test.ts @@ -9,7 +9,7 @@ vi.mock('@/lib/config', () => ({ })) import { getConfig } from '@/lib/config' -const { findByImdbId, searchByTitle, lookupCriterionSlug, fetchWatchProviders, fetchProviderList } = await import('@/lib/tmdb') +const { findByImdbId, searchByTitle, lookupCriterionSlug, fetchWatchProviders, fetchProviderList, fetchPopularMovies } = await import('@/lib/tmdb') const mockConfig = { tmdbApiKey: 'test-key', @@ -223,3 +223,41 @@ describe('fetchProviderList', () => { expect(await fetchProviderList('US')).toEqual([]) }) }) + +describe('fetchPopularMovies', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.mocked(getConfig).mockResolvedValue(mockConfig) + }) + + it('resolves full details for each popular result', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ results: [{ id: 345911 }] }), + }) + .mockResolvedValueOnce({ ok: true, json: async () => detailsResponse }) + + const result = await fetchPopularMovies(1) + expect(result).toHaveLength(1) + expect(result[0].title).toBe('Seven Samurai') + }) + + it('returns empty array on fetch error', async () => { + mockFetch.mockResolvedValueOnce({ ok: false }) + expect(await fetchPopularMovies(1)).toEqual([]) + }) + + it('filters out results TMDB details couldn\'t resolve', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ results: [{ id: 1 }, { id: 2 }] }), + }) + .mockResolvedValueOnce({ ok: false }) // details for id 1 fail + .mockResolvedValueOnce({ ok: true, json: async () => detailsResponse }) // id 2 succeeds + + const result = await fetchPopularMovies(1) + expect(result).toHaveLength(1) + }) +})