mirror of
https://github.com/sbondCo/Watcharr.git
synced 2026-08-07 07:14:44 +00:00
Compare commits
183 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20fd6decf6 | |||
| beaf935c7f | |||
| 1876f53ba4 | |||
| 5df24a8327 | |||
| 2ad979a7c5 | |||
| d8e18ad854 | |||
| 8450880cc9 | |||
| 937e83b17b | |||
| 624d0eef23 | |||
| 27315bb890 | |||
| 15dfcfd477 | |||
| 2f2ed4ee4e | |||
| 971de1b458 | |||
| 3cedd75abd | |||
| 3486fb1521 | |||
| f26d7bb846 | |||
| 8e8fe57a6e | |||
| 63d4d276fe | |||
| 6fa49603a9 | |||
| b1e149a0ed | |||
| 7ac83d4744 | |||
| db26be7033 | |||
| 213264ad23 | |||
| 751674e60f | |||
| 28baca9e2a | |||
| 70ecdf82d7 | |||
| 5b572edd34 | |||
| 8faa43b919 | |||
| 27f362f0a1 | |||
| 290654b17a | |||
| 54178bd8e6 | |||
| 444e884d5f | |||
| 944492375b | |||
| 3fbef9171d | |||
| d2a08367e2 | |||
| 7c3b8cd5ce | |||
| a1f9318ef3 | |||
| 7785a07bb0 | |||
| c09f4a1be5 | |||
| 8abb29ac45 | |||
| 7313910483 | |||
| 9625378eb5 | |||
| ff0eb750bb | |||
| a8a3960705 | |||
| 0ecf8ef145 | |||
| 707a748bb2 | |||
| eec1d6a4c3 | |||
| 8e43ee21a7 | |||
| 80b808c39e | |||
| eb27892e9a | |||
| 889c7e32d2 | |||
| ca1a91e992 | |||
| 635eb7609c | |||
| 3fefdfdfb8 | |||
| 43ffa6d694 | |||
| 23164858f8 | |||
| 2134e78e47 | |||
| 32370e2ec2 | |||
| 8c23dd1b95 | |||
| 9a1df59fc5 | |||
| 8ec1ea2c4a | |||
| d393072b4a | |||
| 074f3a0b19 | |||
| e896be4429 | |||
| 0665666306 | |||
| 751a06513b | |||
| 342c61e9bd | |||
| dd5889ff24 | |||
| 0b86e09f00 | |||
| b62beee787 | |||
| 0207fe82c9 | |||
| 75bb481cd4 | |||
| 6c7c9d8294 | |||
| a145c95d90 | |||
| e426d9be3a | |||
| 8d51e48b37 | |||
| 150f377920 | |||
| 4432fc7e13 | |||
| ef78a84890 | |||
| d6bff05bfb | |||
| 66eadfceac | |||
| fad27de764 | |||
| bd24f3507e | |||
| 8dbf1e1f2c | |||
| dbffc9f438 | |||
| e146247113 | |||
| 52653c866e | |||
| f7e7c03f19 | |||
| 8d8b006bb9 | |||
| 6690563645 | |||
| 6401422d8b | |||
| d17835b246 | |||
| e024ba7767 | |||
| 6f26e1c534 | |||
| ccc0bcefc6 | |||
| 0dacde99e5 | |||
| 8004ae3d42 | |||
| 0d25f48635 | |||
| ec966f5708 | |||
| 9719a05993 | |||
| ae4ce82f8c | |||
| ab138db8b2 | |||
| 5c17a1f124 | |||
| a2496a0a2f | |||
| 2d97560a91 | |||
| 771b857f17 | |||
| 86799daa91 | |||
| 6b9da3be6b | |||
| a564e91dfb | |||
| 9cd8b38143 | |||
| 5b31df77a5 | |||
| 4b6bee9c37 | |||
| 91e1e878f9 | |||
| deeec0cb92 | |||
| a02f7e8999 | |||
| 9812b47be3 | |||
| d6ac2df49d | |||
| 0179c75051 | |||
| d4674c6c1a | |||
| d540779c00 | |||
| 0632f729fe | |||
| 3e7f0e3609 | |||
| 71d0b465ef | |||
| 7683d98dbe | |||
| 93828e5469 | |||
| 73ac7245bb | |||
| 513f1d5094 | |||
| fe75448f19 | |||
| 81e19c2e75 | |||
| 69fcbec1e2 | |||
| 9779bb4c69 | |||
| 6af799f71d | |||
| 41740c993d | |||
| 96e4491a52 | |||
| 8e318758cd | |||
| 25e215296e | |||
| ac00d1df6c | |||
| a316197413 | |||
| ff903570f6 | |||
| ae6ec13c41 | |||
| 7f3ec70c92 | |||
| 3aef3fc841 | |||
| fe4ac10338 | |||
| e5ed41185f | |||
| f04b054969 | |||
| e5a070e0cb | |||
| 3bac5a7cee | |||
| 60834a9a04 | |||
| f01f332a13 | |||
| 1e6b8bf9cd | |||
| 5898436ef3 | |||
| 854821cd26 | |||
| 11b8e0498b | |||
| a84078e119 | |||
| d4a29971cb | |||
| 3ce4bc0552 | |||
| bb0c813274 | |||
| 62c466320d | |||
| 247c1f7763 | |||
| 84a7930736 | |||
| e6b5b0d4a0 | |||
| c8100a8bea | |||
| 4a20ada589 | |||
| 07c05f5aca | |||
| 19c40c15ff | |||
| 6c4c889a6d | |||
| e4fe770666 | |||
| 372a4aad45 | |||
| 9a970df9e6 | |||
| 55de77d59b | |||
| e8db43efd4 | |||
| f33b4e674d | |||
| 2dfbd99825 | |||
| a4f238adcc | |||
| 7168e6222f | |||
| c50851e0f4 | |||
| bd91cdf248 | |||
| ee903cb9bd | |||
| 160deb165f | |||
| c7b126cfc8 | |||
| 82390f388f | |||
| 9e66b2ba29 | |||
| 63b5d48bf8 |
@@ -0,0 +1,35 @@
|
||||
name: Development Watcharr Container Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
image: ghcr.io/sbondco/watcharr
|
||||
|
||||
jobs:
|
||||
release-dev-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ env.image }}:dev
|
||||
labels: ${{ env.image }}:${{ github.sha }}
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
env:
|
||||
image: ghcr.io/sbondco/watcharr
|
||||
|
||||
jobs:
|
||||
release-images:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -14,16 +17,6 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
# Run for each dockerfile (one for server and one for ui images)
|
||||
matrix:
|
||||
include:
|
||||
- dockerfile: ./Dockerfile.svr
|
||||
image: ghcr.io/sbondco/watcharr
|
||||
- dockerfile: ./Dockerfile.ui
|
||||
image: ghcr.io/sbondco/watcharr-ui
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
@@ -39,13 +32,13 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ matrix.image }}
|
||||
images: ${{ env.image }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -37,3 +37,6 @@ node_modules
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Docker dev volume
|
||||
container_data/
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Contributing
|
||||
|
||||
Feel free to contribute in any way: bug reports, ideas, etc - everything is welcome!
|
||||
|
||||
For big PRs (lots of changes/big change in the way something works), if you'd like, it can be discussed first in an issue (dont want to waste anyones time!).
|
||||
@@ -1,9 +0,0 @@
|
||||
localhost:80 {
|
||||
# Reverse proxy for frontend
|
||||
reverse_proxy 172.17.0.1:3000
|
||||
|
||||
# Route requests to /api/ to backend
|
||||
handle_path /api/* {
|
||||
reverse_proxy 172.17.0.1:3080
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Backend
|
||||
FROM golang:1.20 AS server
|
||||
|
||||
WORKDIR /server
|
||||
COPY server/*.go server/go.* ./
|
||||
|
||||
RUN go mod download && GOOS=linux go build -o ./watcharr
|
||||
|
||||
# Frontend
|
||||
FROM node:19 AS ui
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json vite.config.ts svelte.config.js tsconfig.json ./
|
||||
COPY ./src ./src
|
||||
COPY ./static ./static
|
||||
|
||||
RUN npm install && npm run build
|
||||
|
||||
# Production
|
||||
FROM debian:12.0 AS runner
|
||||
|
||||
RUN apt-get update && apt-get install ca-certificates -y
|
||||
|
||||
ENV NODE_VERSION=18.13.0
|
||||
RUN apt install -y curl
|
||||
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash
|
||||
ENV NVM_DIR=/root/.nvm
|
||||
RUN . "$NVM_DIR/nvm.sh" && nvm install ${NODE_VERSION}
|
||||
RUN . "$NVM_DIR/nvm.sh" && nvm use v${NODE_VERSION}
|
||||
RUN . "$NVM_DIR/nvm.sh" && nvm alias default v${NODE_VERSION}
|
||||
ENV PATH="/root/.nvm/versions/node/v${NODE_VERSION}/bin/:${PATH}"
|
||||
RUN node --version
|
||||
|
||||
COPY --from=server /server/watcharr /
|
||||
COPY --from=ui /app/build /ui
|
||||
COPY --from=ui /app/package.json /app/package-lock.json /ui
|
||||
RUN cd /ui && npm ci
|
||||
|
||||
EXPOSE 3080
|
||||
|
||||
CMD ["/watcharr"]
|
||||
@@ -1,16 +0,0 @@
|
||||
# Backend
|
||||
FROM golang:1.20 AS server
|
||||
|
||||
WORKDIR /server
|
||||
COPY server/*.go server/go.* ./
|
||||
|
||||
RUN go mod download && GOOS=linux go build -o ./watcharr
|
||||
|
||||
# Production
|
||||
FROM debian:11.6 AS runner
|
||||
|
||||
COPY --from=server /server/watcharr /
|
||||
|
||||
EXPOSE 3080
|
||||
|
||||
CMD ["/watcharr"]
|
||||
@@ -1,20 +0,0 @@
|
||||
# Frontend
|
||||
FROM node:19 AS ui
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json vite.config.ts svelte.config.js tsconfig.json ./
|
||||
COPY ./src ./src
|
||||
COPY ./static ./static
|
||||
|
||||
RUN npm install && npm run build
|
||||
|
||||
# Production
|
||||
FROM node:19 AS runner
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=ui /app/package*.json /app/build ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
@@ -1,8 +1,76 @@
|
||||
<center>
|
||||
<h1>Watcharr</h1>
|
||||
<img src="./static/logo-col.png" alt="logo" />
|
||||
</center>
|
||||
<h1 align="center">Watcharr</h1>
|
||||
<p align="center"><img src="./static/logo-col.png" alt="logo" width="250" /></p>
|
||||
|
||||
I hope to become the place you store your watched content for later rememberance, but sadly I am still being built. I aspire to be easily self hosted.
|
||||
<p align="center">
|
||||
<a href="https://github.com/sbondCo/Watcharr/pkgs/container/watcharr"><img src="https://img.shields.io/github/v/release/sbondCo/Watcharr?label=version&style=for-the-badge" /></a>
|
||||
<a href="https://watcharr.lab.sbond.co"><img src="https://img.shields.io/website?label=DEMO&style=for-the-badge&url=https%3A%2F%2Fwatcharr.lab.sbond.co%2F" /></a>
|
||||
<a href="https://github.com/sbondCo/Watcharr/issues"><img src="https://img.shields.io/github/issues-raw/sbondCo/Watcharr?label=ISSUES&style=for-the-badge" /></a>
|
||||
<a href="/LICENSE"><img src="https://img.shields.io/github/license/sbondCo/Watcharr?style=for-the-badge" /></a>
|
||||
</p>
|
||||
|
||||
I'm being build with Go and Svelte(Kit).
|
||||
I'm your new easily self-hosted content watched list. The place you store your watched (or watching, planned, etc) movies and tv shows, rate them and track their status.
|
||||
|
||||
I am built with Go and Svelte(Kit).
|
||||
|
||||
Feel free to abuse this demo instance (nicely), which runs on the latest `dev` build (there may be bugs, as new features are tested on here too): [https://watcharr.lab.sbond.co/](https://watcharr.lab.sbond.co/)
|
||||
|
||||
[Track Progress Until Next Version](https://github.com/orgs/sbondCo/projects/9/views/3)
|
||||
|
||||
### Contents
|
||||
|
||||
- [Screenshots](#screenshots)
|
||||
- [Setup](#set-up)
|
||||
|
||||
# Screenshots
|
||||
|
||||
<h3 align="center">Watched List</h3>
|
||||
<p align="center">
|
||||
<img src="./screenshot/homepage.png" alt="Watched List" />
|
||||
|
||||
| Watched Show Hover | Watched Show Status Change | Show Details |
|
||||
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| <img src="./screenshot/homepage-poster-hover.png" alt="Watched List" /> | <img src="./screenshot/homepage-poster-change-status.png" alt="Changing Show Status" /> | <img src="./screenshot/show-details-page.png" alt="Content Details Page" /> |
|
||||
|
||||
</p>
|
||||
|
||||
# Set Up
|
||||
|
||||
Currently only a [docker image](https://github.com/sbondCo/Watcharr/pkgs/container/watcharr) is provided (**v1.9.1 and newer no longer require the `watcharr-ui` package, it is bundled inside the main `watcharr` package**).
|
||||
|
||||
Here is a simple setup you can use yourself, using docker compose:
|
||||
|
||||
**docker-compose.yml**
|
||||
|
||||
```
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
watcharr:
|
||||
# The :latest tag is used for simplicity, it is recommended
|
||||
# to use an actual version, then when updating check the releases for changelogs.
|
||||
image: ghcr.io/sbondco/watcharr:latest
|
||||
container_name: watcharr
|
||||
ports:
|
||||
- 3080:3080
|
||||
volumes:
|
||||
# .env file used to configure watcharr
|
||||
- .env:/.env
|
||||
# Contains all of watcharr data (database & cache)
|
||||
- ./data:/data
|
||||
```
|
||||
|
||||
**.env**
|
||||
|
||||
```
|
||||
# Used to sign JWT tokens. Make sure to make
|
||||
# it strong, just like a very long, complicated password.
|
||||
JWT_SECRET=MAKE_ME_RANDOM_AND_LONG
|
||||
|
||||
# Optional: Point to your Jellyfin install
|
||||
# to enable it as an auth provider.
|
||||
JELLYFIN_HOST=https://my.jellyfin.example
|
||||
|
||||
# Optional: When not set we assume production, should only
|
||||
# be set to DEV when developing the app.
|
||||
MODE=prod
|
||||
```
|
||||
|
||||
+1
-11
@@ -5,18 +5,8 @@ services:
|
||||
watcharr:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.ui
|
||||
dockerfile: Dockerfile
|
||||
container_name: watcharr
|
||||
ports:
|
||||
- 3000:3000
|
||||
depends_on:
|
||||
- watcharr-server
|
||||
|
||||
watcharr-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.svr
|
||||
container_name: watcharr-server
|
||||
ports:
|
||||
- 3080:3080
|
||||
volumes:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
# Watcharr API
|
||||
watcharr:
|
||||
image: ghcr.io/sbondco/watcharr:latest
|
||||
container_name: watcharr
|
||||
@@ -10,20 +9,3 @@ services:
|
||||
volumes:
|
||||
- ./server/.env:/.env
|
||||
- ./container_data:/data
|
||||
|
||||
# Watcharr Frontend
|
||||
watcharr-ui:
|
||||
image: ghcr.io/sbondco/watcharr-ui:latest
|
||||
container_name: watcharr-ui
|
||||
ports:
|
||||
- 3000:3000
|
||||
depends_on:
|
||||
- watcharr
|
||||
|
||||
caddy:
|
||||
image: caddy:2.6
|
||||
container_name: watcharr-caddy
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
|
||||
Generated
+15
-15
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "1.3.0",
|
||||
"version": "1.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "watcharr",
|
||||
"version": "1.3.0",
|
||||
"version": "1.10.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^1.2.3",
|
||||
"@sveltejs/adapter-node": "^1.2.4",
|
||||
"@sveltejs/kit": "^1.15.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.57.1",
|
||||
"@typescript-eslint/parser": "^5.57.1",
|
||||
@@ -535,9 +535,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@rollup/plugin-commonjs": {
|
||||
"version": "24.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-24.0.1.tgz",
|
||||
"integrity": "sha512-15LsiWRZk4eOGqvrJyu3z3DaBu5BhXIMeWnijSRvd8irrrg9SHpQ1pH+BUK4H6Z9wL9yOxZJMTLU+Au86XHxow==",
|
||||
"version": "24.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-24.1.0.tgz",
|
||||
"integrity": "sha512-eSL45hjhCWI0jCCXcNtLVqM5N1JlBGvlFfY0m6oOYnLCJ6N0qEXoZql4sY2MOUArzhH4SA/qBpTxvvZp2Sc+DQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@rollup/pluginutils": "^5.0.1",
|
||||
@@ -632,9 +632,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-node-resolve": {
|
||||
"version": "15.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.0.2.tgz",
|
||||
"integrity": "sha512-Y35fRGUjC3FaurG722uhUuG8YHOJRJQbI6/CkbRkdPotSpDj9NtIN85z1zrcyDcCQIW4qp5mgG72U+gJ0TAFEg==",
|
||||
"version": "15.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.1.0.tgz",
|
||||
"integrity": "sha512-xeZHCgsiZ9pzYVgAo9580eCGqwh/XCEUM9q6iQfGNocjgkufHAqC3exA+45URvhiYV8sBF9RlBai650eNs7AsA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@rollup/pluginutils": "^5.0.1",
|
||||
@@ -679,9 +679,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/adapter-node": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-1.2.3.tgz",
|
||||
"integrity": "sha512-Fv6NyVpVWYA63KRaV6dDjcU8ytcWFiUr0siJOoHl+oWy5WHNEuRiJOUdiZzYbZo8MmvFaCoxHkTgPrVQhpqaRA==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-1.2.4.tgz",
|
||||
"integrity": "sha512-TNnhS+OKRZ9RKnC+ho5mlE2FJquI61i0v7yOXxBUSU3LAoYH2kwVVL8P8ecjefmZ8BOfM1V54pBnDODBU5CEaA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@rollup/plugin-commonjs": "^24.0.0",
|
||||
@@ -765,9 +765,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz",
|
||||
"integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz",
|
||||
"integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "1.3.0",
|
||||
"version": "1.10.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -12,7 +12,7 @@
|
||||
"format": "prettier --plugin-search-dir . --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^1.2.3",
|
||||
"@sveltejs/adapter-node": "^1.2.4",
|
||||
"@sveltejs/kit": "^1.15.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.57.1",
|
||||
"@typescript-eslint/parser": "^5.57.1",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 421 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 432 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 425 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
@@ -0,0 +1,7 @@
|
||||
# Used to sign JWT tokens. Make sure to make
|
||||
# it strong, just like a very long, complicated password.
|
||||
JWT_SECRET=ME_A_DEVELOPEER
|
||||
|
||||
# Optional: When not set we assume production, should only
|
||||
# be set to DEV when developing the app.
|
||||
MODE=DEV
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ActivityType string
|
||||
|
||||
var (
|
||||
ADDED_WATCHED ActivityType = "ADDED_WATCHED"
|
||||
REMOVED_WATCHED ActivityType = "REMOVED_WATCHED"
|
||||
RATING_CHANGED ActivityType = "RATING_CHANGED"
|
||||
STATUS_CHANGED ActivityType = "STATUS_CHANGED"
|
||||
)
|
||||
|
||||
type Activity struct {
|
||||
GormModel
|
||||
// ID of user this activity is linked to, so it can be easily
|
||||
// secured (users can only view their own activities).
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
// ID of watched list item this activity is linked to.
|
||||
WatchedID uint `json:"watchedId" gorm:"not null"`
|
||||
// Type of activity.
|
||||
Type ActivityType `json:"type" gorm:"not null"`
|
||||
// Holds custom data (ex, if rating changed, this can
|
||||
// hold new rating - if status changed, this will hold that).
|
||||
Data string `json:"data" gorm:"not null"`
|
||||
}
|
||||
|
||||
type ActivityAddRequest struct {
|
||||
WatchedID uint `json:"watchedId" binding:"required"`
|
||||
Type ActivityType `json:"type" binding:"required"`
|
||||
Data string `json:"data" binding:"required"`
|
||||
}
|
||||
|
||||
func getActivity(db *gorm.DB, userId uint, watchedId uint) ([]Activity, error) {
|
||||
activity := new([]Activity)
|
||||
res := db.Model(&Activity{}).Where("user_id = ? AND watched_id = ?", userId, watchedId).Find(&activity)
|
||||
if res.Error != nil {
|
||||
println("Failed getting activity from database:", res.Error.Error())
|
||||
return []Activity{}, errors.New("failed getting activity")
|
||||
}
|
||||
return *activity, nil
|
||||
}
|
||||
|
||||
func addActivity(db *gorm.DB, userId uint, ar ActivityAddRequest) (Activity, error) {
|
||||
if ar.WatchedID == 0 {
|
||||
return Activity{}, errors.New("watchedId must be set to add an activity")
|
||||
}
|
||||
activity := Activity{UserID: userId, WatchedID: ar.WatchedID, Type: ar.Type, Data: ar.Data}
|
||||
res := db.Create(&activity)
|
||||
if res.Error != nil {
|
||||
println("Error adding activity to database:", res.Error.Error())
|
||||
return Activity{}, errors.New("failed adding new activity to database")
|
||||
}
|
||||
fmt.Printf("%+v\n", activity)
|
||||
return activity, nil
|
||||
}
|
||||
+116
-3
@@ -1,12 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -17,11 +22,37 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserType uint8
|
||||
|
||||
var (
|
||||
// Assume watcharr user if none of these...
|
||||
JELLYFIN_USER UserType = 1
|
||||
)
|
||||
|
||||
// uniqueIndex applied between Username and UserType, so same usernames can exist, but only with different types.
|
||||
// This is incase different users with same name from different services try to signup.
|
||||
type User struct {
|
||||
GormModel
|
||||
Username string `gorm:"unique;not null" json:"username" binding:"required"`
|
||||
Username string `gorm:"uniqueIndex:usr_name_to_type;not null" json:"username" binding:"required"`
|
||||
Password string `gorm:"not null" json:"password" binding:"required"`
|
||||
Watched []Watched
|
||||
// The type of user/which auth service they originate from.
|
||||
// Empty if from Watcharr, or the name of the service (eg. jellyfin)
|
||||
Type UserType `gorm:"uniqueIndex:usr_name_to_type;not null;default:0" json:"type"`
|
||||
// ID of user from the third party service, this will be used purely for lookup of user at signin.
|
||||
ThirdPartyID string `json:"-"`
|
||||
Watched []Watched
|
||||
}
|
||||
|
||||
type JellyfinAuth struct {
|
||||
Username string `json:"Username"`
|
||||
Pw string `json:"Pw"`
|
||||
}
|
||||
|
||||
type JellyfinAuthResponse struct {
|
||||
User struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
} `json:"User"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
@@ -112,7 +143,7 @@ func register(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
func login(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
fmt.Println("Logging in", user.Username)
|
||||
dbUser := new(User)
|
||||
res := db.Where("username = ?", user.Username).Take(&dbUser)
|
||||
res := db.Where("username = ? AND (type IS NULL OR type = 0)", user.Username).Take(&dbUser)
|
||||
if res.Error != nil {
|
||||
fmt.Println("Failed to select user from database for login:", res.Error)
|
||||
return AuthResponse{}, errors.New("User does not exist")
|
||||
@@ -136,6 +167,88 @@ func login(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
|
||||
func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
jellyfinHost := os.Getenv("JELLYFIN_HOST")
|
||||
if jellyfinHost == "" {
|
||||
println("Request made to login via Jellyfin, but JELLYFIN_HOST environment variable is not set.")
|
||||
return AuthResponse{}, errors.New("jellyfin login not enabled")
|
||||
}
|
||||
|
||||
base, err := url.Parse(jellyfinHost + "/Users/AuthenticateByName")
|
||||
if err != nil {
|
||||
println("Failed to parse AuthenticateByName api endpoint url:", err.Error())
|
||||
return AuthResponse{}, errors.New("failed to parse api uri")
|
||||
}
|
||||
|
||||
// Marshall struct as json
|
||||
usrJSON, err := json.Marshal(JellyfinAuth{Username: user.Username, Pw: user.Password})
|
||||
if err != nil {
|
||||
println("Error marshalling JellyfinAuth JSON", err.Error())
|
||||
return AuthResponse{}, errors.New("failed to marshal json")
|
||||
}
|
||||
// Run auth request
|
||||
// res, err := http.Post(base.String(), "application/json", bytes.NewBuffer(usrJSON))
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("POST", base.String(), bytes.NewBuffer(usrJSON))
|
||||
if err != nil {
|
||||
println("creating request to jellyfin for auth failed:", err)
|
||||
return AuthResponse{}, errors.New("request failed")
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Add("X-Emby-Authorization", "MediaBrowser Client=\"Watcharr\", Device=\"HTTP\", DeviceId=\"WatcharrFor"+user.Username+"\", Version=\"10.8.0\"")
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
println("making request to jellyfin for auth failed:", err)
|
||||
return AuthResponse{}, errors.New("request failed")
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
println("Error reading response", err.Error())
|
||||
return AuthResponse{}, err
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
println("Jellyfin auth non 200 status code:", res.StatusCode, string(body))
|
||||
return AuthResponse{}, errors.New("incorrect details")
|
||||
}
|
||||
// Process auth response
|
||||
resp := new(JellyfinAuthResponse)
|
||||
err = json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
return AuthResponse{}, errors.New("failed to process response")
|
||||
}
|
||||
if resp.User.ID == "" {
|
||||
return AuthResponse{}, errors.New("jellyfin returned empty user id")
|
||||
}
|
||||
|
||||
dbUser := new(User)
|
||||
dbRes := db.Where("third_party_id = ?", resp.User.ID).Take(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
if errors.Is(dbRes.Error, gorm.ErrRecordNotFound) {
|
||||
// Record not found, so we should create the user
|
||||
// dbUser will be empty, so we can just reuse it for this purpose.
|
||||
dbUser.ThirdPartyID = resp.User.ID
|
||||
dbUser.Username = resp.User.Name
|
||||
dbUser.Type = JELLYFIN_USER
|
||||
|
||||
dbRes = db.Create(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
println("Failed to create new user in db from jellyfin response:", err.Error())
|
||||
return AuthResponse{}, errors.New("failed to create new user from jellyfin")
|
||||
}
|
||||
} else {
|
||||
return AuthResponse{}, errors.New("error locating user in db")
|
||||
}
|
||||
}
|
||||
|
||||
token, err := signJWT(dbUser)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to sign new jwt:", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
}
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
|
||||
func signJWT(user *User) (token string, err error) {
|
||||
// Create new jwt with claim data
|
||||
jwt := jwt.NewWithClaims(jwt.SigningMethodHS256, TokenClaims{
|
||||
|
||||
+88
-4
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ContentType string
|
||||
@@ -13,10 +14,23 @@ const (
|
||||
|
||||
// For storing cached content, so we can serve the basic local data for watched list to work
|
||||
type Content struct {
|
||||
ID int `json:"id" gorm:"primaryKey"`
|
||||
Title string `json:"title"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
Overview string `json:"overview"`
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
TmdbID int `json:"tmdbId" gorm:"uniqueIndex:contentidtotypeidx;not null"`
|
||||
Title string `json:"title"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
Overview string `json:"overview"`
|
||||
Type ContentType `json:"type" gorm:"uniqueIndex:contentidtotypeidx;not null"`
|
||||
ReleaseDate time.Time `json:"release_date"`
|
||||
Popularity float32 `json:"popularity"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
ImdbID string `json:"imdb_id"`
|
||||
Status string `json:"status"`
|
||||
Budget uint32 `json:"budget"`
|
||||
Revenue uint32 `json:"revenue"`
|
||||
Runtime uint32 `json:"runtime"`
|
||||
NumberOfEpisodes uint32 `json:"numberOfEpisodes"`
|
||||
NumberOfSeasons uint32 `json:"numberOfSeasons"`
|
||||
}
|
||||
|
||||
func searchContent(query string) (TMDBSearchMultiResponse, error) {
|
||||
@@ -28,3 +42,73 @@ func searchContent(query string) (TMDBSearchMultiResponse, error) {
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func movieDetails(id string) (TMDBMovieDetails, error) {
|
||||
resp := new(TMDBMovieDetails)
|
||||
err := tmdbRequest("/movie/"+id, map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete movie details request!", err.Error())
|
||||
return TMDBMovieDetails{}, errors.New("failed to complete movie details request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func movieCredits(id string) (TMDBContentCredits, error) {
|
||||
resp := new(TMDBContentCredits)
|
||||
err := tmdbRequest("/movie/"+id+"/credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete movie cast request!", err.Error())
|
||||
return TMDBContentCredits{}, errors.New("failed to complete movie cast request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func tvDetails(id string) (TMDBShowDetails, error) {
|
||||
resp := new(TMDBShowDetails)
|
||||
err := tmdbRequest("/tv/"+id, map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete tv details request!", err.Error())
|
||||
return TMDBShowDetails{}, errors.New("failed to complete tv details request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func tvCredits(id string) (TMDBContentCredits, error) {
|
||||
resp := new(TMDBContentCredits)
|
||||
err := tmdbRequest("/tv/"+id+"/credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete tv cast request!", err.Error())
|
||||
return TMDBContentCredits{}, errors.New("failed to complete tv cast request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func seasonDetails(tvId string, seasonNumber string) (TMDBSeasonDetails, error) {
|
||||
resp := new(TMDBSeasonDetails)
|
||||
err := tmdbRequest("/tv/"+tvId+"/season/"+seasonNumber, map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete season details request!", err.Error())
|
||||
return TMDBSeasonDetails{}, errors.New("failed to complete season details request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func personDetails(id string) (TMDBPersonDetails, error) {
|
||||
resp := new(TMDBPersonDetails)
|
||||
err := tmdbRequest("/person/"+id, map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete person details request!", err.Error())
|
||||
return TMDBPersonDetails{}, errors.New("failed to complete person details request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func personCredits(id string) (TMDBPersonCombinedCredits, error) {
|
||||
resp := new(TMDBPersonCombinedCredits)
|
||||
err := tmdbRequest("/person/"+id+"/combined_credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete person details request!", err.Error())
|
||||
return TMDBPersonCombinedCredits{}, errors.New("failed to complete person details request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
+17
-17
@@ -5,15 +5,15 @@ go 1.20
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.4.0
|
||||
github.com/gin-gonic/gin v1.9.0
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0-rc.1
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.7
|
||||
github.com/uptrace/bun v1.1.12
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.12
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.12
|
||||
golang.org/x/crypto v0.7.0
|
||||
gorm.io/driver/sqlite v1.4.4
|
||||
gorm.io/gorm v1.24.6
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/uptrace/bun v1.1.14
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.14
|
||||
golang.org/x/crypto v0.9.0
|
||||
gorm.io/driver/sqlite v1.5.1
|
||||
gorm.io/gorm v1.25.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -32,7 +32,7 @@ require (
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/leodido/go-urn v1.2.2 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.16 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
@@ -44,21 +44,21 @@ require (
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/mod v0.9.0 // indirect
|
||||
golang.org/x/net v0.8.0 // indirect
|
||||
golang.org/x/sys v0.6.0 // indirect
|
||||
golang.org/x/text v0.8.0 // indirect
|
||||
golang.org/x/tools v0.6.0 // indirect
|
||||
golang.org/x/mod v0.10.0 // indirect
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
golang.org/x/sys v0.8.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
golang.org/x/tools v0.9.1 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
lukechampine.com/uint128 v1.3.0 // indirect
|
||||
modernc.org/cc/v3 v3.40.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||
modernc.org/libc v1.22.3 // indirect
|
||||
modernc.org/libc v1.22.6 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/sqlite v1.21.0 // indirect
|
||||
modernc.org/sqlite v1.22.1 // indirect
|
||||
modernc.org/strutil v1.1.3 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
|
||||
@@ -33,6 +33,8 @@ github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA=
|
||||
github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0-rc.1 h1:tDQ1LjKga657layZ4JLsRdxgvupebc0xuPwRNuTfUgs=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0-rc.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -65,9 +67,13 @@ github.com/leodido/go-urn v1.2.2 h1:7z68G0FCGvDk646jz1AelTYNYWrTNm0bEcFAo147wt4=
|
||||
github.com/leodido/go-urn v1.2.2/go.mod h1:kUaIbLZWttglzwNuG0pgsh5vuV6u2YcGBYz1hIPjtOQ=
|
||||
github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
|
||||
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
@@ -112,10 +118,16 @@ github.com/ugorji/go/codec v1.2.10 h1:eimT6Lsr+2lzmSZxPhLFoOWFmQqwk0fllJJ5hEbTXt
|
||||
github.com/ugorji/go/codec v1.2.10/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ=
|
||||
github.com/uptrace/bun v1.1.12/go.mod h1:NPG6JGULBeQ9IU6yHp7YGELRa5Agmd7ATZdz4tGZ6z0=
|
||||
github.com/uptrace/bun v1.1.14 h1:S5vvNnjEynJ0CvnrBOD7MIRW7q/WbtvFXrdfy0lddAM=
|
||||
github.com/uptrace/bun v1.1.14/go.mod h1:RHk6DrIisO62dv10pUOJCz5MphXThuOTpVNYEYv7NI8=
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.12 h1:Ud31nqZmebcQpl151nb108+vtcpxJ7kfXmbPYbALBiI=
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.12/go.mod h1:Pwg7s31BdF3PMBlWTnYkEn2I9ASsvatt1Ln/AERCTV4=
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14 h1:SlwXLxr+N1kEo8Q0cheRlnIZLZlWniEB1OI+jkiLgWE=
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14/go.mod h1:9RTEj1l4bB9a4l1Mnc9y4COTwWlFYe1dh6fyxq1rR7A=
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.12 h1:GMbSa7Pjjk4kjF8XURz5uMLe2PbN98e6t00sp0rx2Eo=
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.12/go.mod h1:u67g2ewzoMDCCAqjliHAM/BJjEXfoExXlFXhx3TnXRs=
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.14 h1:DFPUJ6KjDP2myjq15gtYYNngmAFMww1Y2UFZv4tbUw8=
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.14/go.mod h1:5BFN7V6Sm37Tn7UE4FWNm/F6V3iJPUzAJ7QyRwA5b1k=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
@@ -129,15 +141,21 @@ golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
|
||||
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
||||
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
|
||||
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -148,6 +166,8 @@ golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -155,9 +175,13 @@ golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
|
||||
golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
@@ -174,11 +198,19 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc=
|
||||
gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
||||
gorm.io/driver/sqlite v1.5.1 h1:hYyrLkAWE71bcarJDPdZNTLWtr8XrSjOWyjUYI6xdL4=
|
||||
gorm.io/driver/sqlite v1.5.1/go.mod h1:7MZZ2Z8bqyfSQA1gYEV6MagQWj3cpUkJj9Z+d1HEMEQ=
|
||||
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||
gorm.io/gorm v1.24.6 h1:wy98aq9oFEetsc4CAbKD2SoBCdMzsbSIvSUUFJuHi5s=
|
||||
gorm.io/gorm v1.24.6/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.0 h1:+KtYtb2roDz14EQe4bla8CbQlmb9dN3VejSai3lprfU=
|
||||
gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.1 h1:nsSALe5Pr+cM3V1qwwQ7rOkw+6UeLrX5O4v3llhHa64=
|
||||
gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
|
||||
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||
@@ -187,6 +219,8 @@ modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0=
|
||||
modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug=
|
||||
modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY=
|
||||
modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw=
|
||||
modernc.org/libc v1.22.6 h1:cbXU8R+A6aOjRuhsFh3nbDWXO/Hs4ClJRXYB11KmPDo=
|
||||
modernc.org/libc v1.22.6/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
@@ -197,6 +231,8 @@ modernc.org/sqlite v1.20.4 h1:J8+m2trkN+KKoE7jglyHYYYiaq5xmz2HoHJIiBlRzbE=
|
||||
modernc.org/sqlite v1.20.4/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=
|
||||
modernc.org/sqlite v1.21.0 h1:4aP4MdUf15i3R3M2mx6Q90WHKz3nZLoz96zlB6tNdow=
|
||||
modernc.org/sqlite v1.21.0/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI=
|
||||
modernc.org/sqlite v1.22.1 h1:P2+Dhp5FR1RlVRkQ3dDfCiv3Ok8XPxqpe70IjYVA9oE=
|
||||
modernc.org/sqlite v1.22.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
Joined time.Time `json:"joined"`
|
||||
ShowsWatched int32 `json:"showsWatched"`
|
||||
MoviesWatched int32 `json:"moviesWatched"`
|
||||
}
|
||||
|
||||
// Gets any data required for profile page
|
||||
func getProfile(db *gorm.DB, userId uint) (Profile, error) {
|
||||
user := new(User)
|
||||
res := db.Model(&User{}).Where("id = ?", userId).Take(&user)
|
||||
if res.Error != nil {
|
||||
println("Failed to get profile:", res.Error.Error())
|
||||
return Profile{}, errors.New("failed to get profile")
|
||||
}
|
||||
watched := new([]Watched)
|
||||
res = db.Model(&Watched{}).Preload("Content").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
println("Profile: Failed to get watched for processing:", res.Error.Error())
|
||||
return Profile{}, errors.New("failed to get watched for processing")
|
||||
}
|
||||
var (
|
||||
showsWatched int32
|
||||
moviesWatched int32
|
||||
)
|
||||
for _, w := range *watched {
|
||||
if w.Status == FINISHED {
|
||||
if w.Content.Type == SHOW {
|
||||
showsWatched++
|
||||
} else if w.Content.Type == MOVIE {
|
||||
moviesWatched++
|
||||
}
|
||||
}
|
||||
}
|
||||
profile := Profile{Joined: user.CreatedAt, ShowsWatched: showsWatched, MoviesWatched: moviesWatched}
|
||||
return profile, nil
|
||||
}
|
||||
+189
-2
@@ -14,10 +14,10 @@ type ErrorResponse struct {
|
||||
|
||||
type BaseRouter struct {
|
||||
db *gorm.DB
|
||||
rg *gin.Engine
|
||||
rg *gin.RouterGroup
|
||||
}
|
||||
|
||||
func newBaseRouter(db *gorm.DB, rg *gin.Engine) *BaseRouter {
|
||||
func newBaseRouter(db *gorm.DB, rg *gin.RouterGroup) *BaseRouter {
|
||||
return &BaseRouter{
|
||||
db: db,
|
||||
rg: rg,
|
||||
@@ -46,6 +46,104 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
|
||||
// Get movie details
|
||||
content.GET("/movie/:id", func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := movieDetails(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
|
||||
// Get movie cast
|
||||
content.GET("/movie/:id/credits", func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := movieCredits(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
|
||||
// Get tv details
|
||||
content.GET("/tv/:id", func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := tvDetails(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
|
||||
// Get tv cast
|
||||
content.GET("/tv/:id/credits", func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := tvCredits(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
|
||||
// Get season details
|
||||
content.GET("/tv/:id/season/:num", func(c *gin.Context) {
|
||||
if c.Param("id") == "" || c.Param("num") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := seasonDetails(c.Param("id"), c.Param("num"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
|
||||
// Get person details
|
||||
content.GET("/person/:id", func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := personDetails(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
|
||||
// Get person credits
|
||||
content.GET("/person/:id/credits", func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := personCredits(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addWatchedRoutes() {
|
||||
@@ -92,6 +190,60 @@ func (b *BaseRouter) addWatchedRoutes() {
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
watched.DELETE(":id", func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
if err == nil {
|
||||
response, err := removeWatched(b.db, userId, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addActivityRoutes() {
|
||||
activity := b.rg.Group("/activity").Use(AuthRequired())
|
||||
|
||||
activity.GET(":watchedId", func(c *gin.Context) {
|
||||
watchedId, err := strconv.ParseUint(c.Param("watchedId"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "check watched id route param"})
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
activity, err := getActivity(b.db, userId, uint(watchedId))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, activity)
|
||||
})
|
||||
|
||||
activity.POST("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar ActivityAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := addActivity(b.db, userId, ar)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addAuthRoutes() {
|
||||
@@ -112,6 +264,21 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
c.Status(400)
|
||||
})
|
||||
|
||||
// Jellyfin login
|
||||
auth.POST("/jellyfin", func(c *gin.Context) {
|
||||
var user User
|
||||
if c.ShouldBindJSON(&user) == nil {
|
||||
response, err := loginJellyfin(&user, b.db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.Status(400)
|
||||
})
|
||||
|
||||
// Register
|
||||
auth.POST("/register", func(c *gin.Context) {
|
||||
var user User
|
||||
@@ -126,4 +293,24 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
}
|
||||
c.Status(400)
|
||||
})
|
||||
|
||||
// Get available auth providers
|
||||
auth.GET("/available", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, AvailableAuthProviders)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addProfileRoutes() {
|
||||
profile := b.rg.Group("/profile").Use(AuthRequired())
|
||||
|
||||
// Get user profile details
|
||||
profile.GET("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := getProfile(b.db, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
+142
-12
@@ -24,13 +24,14 @@ type TMDBSearchMultiResults struct {
|
||||
OriginalTitle string `json:"original_title,omitempty"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
GenreIds []int64 `json:"genre_ids"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
Popularity float32 `json:"popularity"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
Video bool `json:"video,omitempty"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
Name string `json:"name,omitempty"`
|
||||
OriginalName string `json:"original_name,omitempty"`
|
||||
FirstAirDate string `json:"first_air_date,omitempty"`
|
||||
@@ -46,7 +47,7 @@ type TMDBContentDetails struct {
|
||||
} `json:"genres"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
Homepage string `json:"homepage"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
Popularity float32 `json:"popularity"`
|
||||
Overview string `json:"overview"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
ProductionCompanies []struct {
|
||||
@@ -61,8 +62,8 @@ type TMDBContentDetails struct {
|
||||
} `json:"production_countries"`
|
||||
Status string `json:"status"`
|
||||
Tagline string `json:"tagline"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
SpokenLanguages []struct {
|
||||
EnglishName string `json:"english_name"`
|
||||
Iso6391 string `json:"iso_639_1"`
|
||||
@@ -74,12 +75,12 @@ type TMDBMovieDetails struct {
|
||||
TMDBContentDetails
|
||||
Adult bool `json:"adult"`
|
||||
BelongsToCollection any `json:"belongs_to_collection"`
|
||||
Budget int `json:"budget"`
|
||||
Budget uint32 `json:"budget"`
|
||||
ImdbID string `json:"imdb_id"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Revenue int `json:"revenue"`
|
||||
Runtime int `json:"runtime"`
|
||||
Revenue uint32 `json:"revenue"`
|
||||
Runtime uint32 `json:"runtime"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
}
|
||||
@@ -108,7 +109,7 @@ type TMDBShowDetails struct {
|
||||
SeasonNumber int `json:"season_number"`
|
||||
StillPath string `json:"still_path"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
} `json:"last_episode_to_air"`
|
||||
Name string `json:"name"`
|
||||
NextEpisodeToAir any `json:"next_episode_to_air"`
|
||||
@@ -118,8 +119,8 @@ type TMDBShowDetails struct {
|
||||
LogoPath string `json:"logo_path"`
|
||||
OriginCountry string `json:"origin_country"`
|
||||
} `json:"networks"`
|
||||
NumberOfEpisodes int `json:"number_of_episodes"`
|
||||
NumberOfSeasons int `json:"number_of_seasons"`
|
||||
NumberOfEpisodes uint32 `json:"number_of_episodes"`
|
||||
NumberOfSeasons uint32 `json:"number_of_seasons"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Seasons []struct {
|
||||
@@ -134,6 +135,135 @@ type TMDBShowDetails struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type TMDBSeasonDetails struct {
|
||||
ID string `json:"_id"`
|
||||
AirDate string `json:"air_date"`
|
||||
Episodes []struct {
|
||||
AirDate string `json:"air_date"`
|
||||
EpisodeNumber int `json:"episode_number"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Overview string `json:"overview"`
|
||||
ProductionCode string `json:"production_code"`
|
||||
Runtime int `json:"runtime"`
|
||||
SeasonNumber int `json:"season_number"`
|
||||
ShowID int `json:"show_id"`
|
||||
StillPath string `json:"still_path"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
Crew []struct {
|
||||
Department string `json:"department"`
|
||||
Job string `json:"job"`
|
||||
CreditID string `json:"credit_id"`
|
||||
Adult bool `json:"adult"`
|
||||
Gender int `json:"gender"`
|
||||
ID int `json:"id"`
|
||||
KnownForDepartment string `json:"known_for_department"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
} `json:"crew"`
|
||||
GuestStars []struct {
|
||||
Character string `json:"character"`
|
||||
CreditID string `json:"credit_id"`
|
||||
Order int `json:"order"`
|
||||
Adult bool `json:"adult"`
|
||||
Gender int `json:"gender"`
|
||||
ID int `json:"id"`
|
||||
KnownForDepartment string `json:"known_for_department"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
} `json:"guest_stars"`
|
||||
} `json:"episodes"`
|
||||
Name string `json:"name"`
|
||||
Overview string `json:"overview"`
|
||||
ID0 int `json:"id"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
SeasonNumber int `json:"season_number"`
|
||||
}
|
||||
|
||||
type TMDBPersonDetails struct {
|
||||
Birthday string `json:"birthday"`
|
||||
KnownForDepartment string `json:"known_for_department"`
|
||||
Deathday string `json:"deathday"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AlsoKnownAs []string `json:"also_known_as"`
|
||||
Gender int8 `json:"gender"`
|
||||
Biography string `json:"biography"`
|
||||
Popularity float32 `json:"popularity"`
|
||||
PlaceOfBirth string `json:"place_of_birth"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
Adult bool `json:"adult"`
|
||||
ImdbID string `json:"imdb_id"`
|
||||
Homepage string `json:"homepage"`
|
||||
}
|
||||
|
||||
type TMDBPersonCombinedCredits struct {
|
||||
ID int `json:"id"`
|
||||
Cast []TMDBPersonCombinedCreditsCast `json:"cast"`
|
||||
// crew TMDBPersonCombinedCreditsCrew
|
||||
}
|
||||
|
||||
type TMDBPersonCombinedCreditsCast struct {
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
EpisodeCount int `json:"episode_count"`
|
||||
Overview string `json:"overview"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalName string `json:"original_name"`
|
||||
GenreIDs []int `json:"genre_ids"`
|
||||
Name string `json:"name"`
|
||||
MediaType string `json:"media_type"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
Character string `json:"character"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
CreditID string `json:"credit_id"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Video bool `json:"video"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Adult bool `json:"adult"`
|
||||
}
|
||||
|
||||
type TMDBContentCredits struct {
|
||||
ID int `json:"id"`
|
||||
Cast []struct {
|
||||
Adult bool `json:"adult"`
|
||||
Gender int `json:"gender"`
|
||||
ID int `json:"id"`
|
||||
KnownForDepartment string `json:"known_for_department"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
CastID int `json:"cast_id"`
|
||||
Character string `json:"character"`
|
||||
CreditID string `json:"credit_id"`
|
||||
Order int `json:"order"`
|
||||
} `json:"cast"`
|
||||
Crew []struct {
|
||||
Adult bool `json:"adult"`
|
||||
Gender int `json:"gender"`
|
||||
ID int `json:"id"`
|
||||
KnownForDepartment string `json:"known_for_department"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
CreditID string `json:"credit_id"`
|
||||
Department string `json:"department"`
|
||||
Job string `json:"job"`
|
||||
} `json:"crew"`
|
||||
}
|
||||
|
||||
func tmdbAPIRequest(ep string, p map[string]string) ([]byte, error) {
|
||||
println("tmdbAPIRequest:", ep)
|
||||
base, err := url.Parse("https://api.themoviedb.org/3")
|
||||
|
||||
+63
-6
@@ -1,9 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
@@ -20,6 +24,8 @@ type GormModel struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
|
||||
var AvailableAuthProviders = []string{}
|
||||
|
||||
func main() {
|
||||
fmt.Println("Watcharr Starting")
|
||||
|
||||
@@ -35,29 +41,76 @@ func main() {
|
||||
log.Fatal("Failed to create data dir:", err)
|
||||
}
|
||||
|
||||
// Check if we want to be in DEV or PROD
|
||||
isProd := true
|
||||
if os.Getenv("MODE") == "DEV" {
|
||||
isProd = false
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("./data/watcharr.db"), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("failed to connect to database")
|
||||
}
|
||||
|
||||
db.AutoMigrate(&User{}, &Content{}, &Watched{})
|
||||
err = db.AutoMigrate(&User{}, &Content{}, &Watched{}, &Activity{})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to auto migrate database:", err)
|
||||
}
|
||||
|
||||
gin := gin.Default()
|
||||
gin.Use(cors.New(cors.Config{
|
||||
if isProd {
|
||||
go runUI()
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
gine := gin.Default()
|
||||
gine.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "OPTIONS"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization", "accept", "origin", "Cache-Control", "X-Requested-With"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
br := newBaseRouter(db, gin)
|
||||
if isProd {
|
||||
// Proxy NoRoute requests to UI server
|
||||
gine.NoRoute(func(c *gin.Context) {
|
||||
director := func(req *http.Request) {
|
||||
req.URL.Scheme = "http"
|
||||
req.URL.Host = "127.0.0.1:3000"
|
||||
}
|
||||
proxy := &httputil.ReverseProxy{Director: director}
|
||||
proxy.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
}
|
||||
br := newBaseRouter(db, gine.Group("/api"))
|
||||
br.addAuthRoutes()
|
||||
br.addContentRoutes()
|
||||
br.addWatchedRoutes()
|
||||
br.addActivityRoutes()
|
||||
br.addProfileRoutes()
|
||||
br.rg.Static("/img", "./data/img")
|
||||
|
||||
gin.Run("0.0.0.0:3080")
|
||||
gine.Run("0.0.0.0:3080")
|
||||
}
|
||||
|
||||
// Run UI server
|
||||
func runUI() {
|
||||
cmd := exec.Command("node", "ui/index.js")
|
||||
cmdReader, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
log.Fatal("UI ERR ", err)
|
||||
}
|
||||
scanner := bufio.NewScanner(cmdReader)
|
||||
go func() {
|
||||
for scanner.Scan() {
|
||||
fmt.Println(scanner.Text())
|
||||
}
|
||||
}()
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Fatal("UI ERR ", err)
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
log.Fatal("UI ERR ", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all required environment variables are set.
|
||||
@@ -65,6 +118,10 @@ func ensureEnv() {
|
||||
if os.Getenv("JWT_SECRET") == "" {
|
||||
log.Fatal("JWT_SECRET env var missing!")
|
||||
}
|
||||
|
||||
if os.Getenv("JELLYFIN_HOST") != "" {
|
||||
AvailableAuthProviders = append(AvailableAuthProviders, "jellyfin")
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDirExists(dir string) error {
|
||||
|
||||
+196
-70
@@ -10,6 +10,7 @@ import (
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -20,6 +21,8 @@ const (
|
||||
FINISHED WatchedStatus = "FINISHED"
|
||||
WATCHING WatchedStatus = "WATCHING"
|
||||
PLANNED WatchedStatus = "PLANNED"
|
||||
HOLD WatchedStatus = "ONHOLD"
|
||||
DROPPED WatchedStatus = "DROPPED"
|
||||
)
|
||||
|
||||
type Watched struct {
|
||||
@@ -29,23 +32,32 @@ type Watched struct {
|
||||
UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx"`
|
||||
ContentID int `json:"-" gorm:"uniqueIndex:usernctnidx"`
|
||||
Content Content `json:"content"`
|
||||
Activity []Activity `json:"activity"`
|
||||
}
|
||||
|
||||
type WatchedAddRequest struct {
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating" binding:"max=5"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
ContentID int `json:"contentId" binding:"required"`
|
||||
ContentType ContentType `json:"contentType" binding:"required,oneof=movie tv"`
|
||||
}
|
||||
|
||||
type WatchedUpdateRequest struct {
|
||||
Status WatchedStatus `json:"status" binding:"required_without=Rating"`
|
||||
Rating int8 `json:"rating" binding:"max=5,required_without=Status"`
|
||||
Rating int8 `json:"rating" binding:"max=10,required_without=Status"`
|
||||
}
|
||||
|
||||
type WatchedUpdateResponse struct {
|
||||
NewActivity Activity `json:"newActivity"`
|
||||
}
|
||||
|
||||
type WatchedRemoveResponse struct {
|
||||
NewActivity Activity `json:"newActivity"`
|
||||
}
|
||||
|
||||
func getWatched(db *gorm.DB, userId uint) []Watched {
|
||||
watched := new([]Watched)
|
||||
res := db.Model(&Watched{}).Preload("Content").Where("user_id = ?", userId).Find(&watched)
|
||||
res := db.Model(&Watched{}).Preload("Content").Preload("Activity").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
@@ -55,95 +67,209 @@ func getWatched(db *gorm.DB, userId uint) []Watched {
|
||||
func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error) {
|
||||
println(ar.ContentType, ar.ContentID)
|
||||
|
||||
resp, err := tmdbAPIRequest("/"+string(ar.ContentType)+"/"+strconv.Itoa(ar.ContentID), map[string]string{})
|
||||
if err != nil {
|
||||
fmt.Printf("addWatched tmdb api request failed: %+v", err)
|
||||
return Watched{}, errors.New("failed to find requested media")
|
||||
}
|
||||
var content Content
|
||||
db.Where("tmdb_id = ?", ar.ContentID).Find(&content)
|
||||
|
||||
var (
|
||||
id int
|
||||
title string
|
||||
overview string
|
||||
posterPath string
|
||||
)
|
||||
// Get details from movie/show response and fill out needed vars
|
||||
if ar.ContentType == "movie" {
|
||||
content := new(TMDBMovieDetails)
|
||||
err = json.Unmarshal([]byte(resp), &content)
|
||||
if err != nil {
|
||||
println("Failed to unmarshal movie details:", err)
|
||||
return Watched{}, errors.New("failed to process movie details response")
|
||||
}
|
||||
id = content.ID
|
||||
overview = content.Overview
|
||||
posterPath = content.PosterPath
|
||||
title = content.Title
|
||||
} else {
|
||||
content := new(TMDBShowDetails)
|
||||
err = json.Unmarshal(resp, &content)
|
||||
if err != nil {
|
||||
println("Failed to unmarshal show details:", err)
|
||||
return Watched{}, errors.New("failed to process show details response")
|
||||
}
|
||||
id = content.ID
|
||||
overview = content.Overview
|
||||
posterPath = content.PosterPath
|
||||
title = content.Name
|
||||
}
|
||||
// Save the content in our db
|
||||
println("id, etc:", id, title, overview, posterPath, "<-- end")
|
||||
if id == 0 || title == "" {
|
||||
println("addWatched, returned content missing id or title!", id, title)
|
||||
return Watched{}, errors.New("content response missing id or title")
|
||||
}
|
||||
content := Content{ID: id, Title: title, Overview: overview, PosterPath: posterPath}
|
||||
res := db.Create(&content)
|
||||
if res.Error != nil {
|
||||
// Error if anything but unique contraint error
|
||||
if !strings.Contains(res.Error.Error(), "UNIQUE") {
|
||||
println("Error creating content in database:", res.Error.Error())
|
||||
return Watched{}, errors.New("failed to cache content in database")
|
||||
}
|
||||
}
|
||||
// If row created, download the image
|
||||
if res.RowsAffected > 0 {
|
||||
err := download("https://image.tmdb.org/t/p/w500"+posterPath, path.Join("./data/img", posterPath))
|
||||
if err != nil {
|
||||
println("Failed to download content image!", err.Error())
|
||||
}
|
||||
}
|
||||
// Create content if not found from our db
|
||||
if content == (Content{}) {
|
||||
println("Content not in db, fetching...")
|
||||
|
||||
resp, err := tmdbAPIRequest("/"+string(ar.ContentType)+"/"+strconv.Itoa(ar.ContentID), map[string]string{})
|
||||
if err != nil {
|
||||
fmt.Printf("addWatched tmdb api request failed: %+v", err)
|
||||
return Watched{}, errors.New("failed to find requested media")
|
||||
}
|
||||
|
||||
var (
|
||||
id int
|
||||
title string
|
||||
overview string
|
||||
posterPath string
|
||||
releaseDate time.Time
|
||||
popularity float32
|
||||
voteAverage float32
|
||||
voteCount uint32
|
||||
imdbID string
|
||||
status string
|
||||
budget uint32
|
||||
revenue uint32
|
||||
runtime uint32
|
||||
numberOfEpisodes uint32
|
||||
numberOfSeasons uint32
|
||||
)
|
||||
var dateFormat = "2006-01-02"
|
||||
// Get details from movie/show response and fill out needed vars
|
||||
if ar.ContentType == "movie" {
|
||||
content := new(TMDBMovieDetails)
|
||||
err = json.Unmarshal([]byte(resp), &content)
|
||||
if err != nil {
|
||||
println("Failed to unmarshal movie details:", err)
|
||||
return Watched{}, errors.New("failed to process movie details response")
|
||||
}
|
||||
id = content.ID
|
||||
overview = content.Overview
|
||||
posterPath = content.PosterPath
|
||||
title = content.Title
|
||||
releaseDate, err = time.Parse(dateFormat, content.ReleaseDate)
|
||||
if err != nil {
|
||||
println("Failed to parse movie release date:", err)
|
||||
}
|
||||
popularity = content.Popularity
|
||||
voteAverage = content.VoteAverage
|
||||
voteCount = content.VoteCount
|
||||
imdbID = content.ImdbID
|
||||
status = content.Status
|
||||
budget = content.Budget
|
||||
revenue = content.Revenue
|
||||
runtime = content.Runtime
|
||||
} else {
|
||||
content := new(TMDBShowDetails)
|
||||
err = json.Unmarshal(resp, &content)
|
||||
if err != nil {
|
||||
println("Failed to unmarshal show details:", err)
|
||||
return Watched{}, errors.New("failed to process show details response")
|
||||
}
|
||||
id = content.ID
|
||||
overview = content.Overview
|
||||
posterPath = content.PosterPath
|
||||
title = content.Name
|
||||
releaseDate, err = time.Parse(dateFormat, content.FirstAirDate)
|
||||
if err != nil {
|
||||
println("Failed to parse tv release date:", err)
|
||||
}
|
||||
popularity = content.Popularity
|
||||
voteAverage = content.VoteAverage
|
||||
voteCount = content.VoteCount
|
||||
status = content.Status
|
||||
if len(content.EpisodeRunTime) > 0 {
|
||||
runtime = uint32(content.EpisodeRunTime[0])
|
||||
}
|
||||
numberOfEpisodes = content.NumberOfEpisodes
|
||||
numberOfSeasons = content.NumberOfSeasons
|
||||
}
|
||||
// Save the content in our db
|
||||
println("id, etc:", id, title, overview, posterPath, "<-- end")
|
||||
if id == 0 || title == "" {
|
||||
println("addWatched, returned content missing id or title!", id, title)
|
||||
return Watched{}, errors.New("content response missing id or title")
|
||||
}
|
||||
content = Content{
|
||||
TmdbID: id,
|
||||
Title: title,
|
||||
Overview: overview,
|
||||
PosterPath: posterPath,
|
||||
Type: ar.ContentType,
|
||||
ReleaseDate: releaseDate,
|
||||
Popularity: popularity,
|
||||
VoteAverage: voteAverage,
|
||||
VoteCount: voteCount,
|
||||
ImdbID: imdbID,
|
||||
Status: status,
|
||||
Budget: budget,
|
||||
Revenue: revenue,
|
||||
Runtime: runtime,
|
||||
NumberOfEpisodes: numberOfEpisodes,
|
||||
NumberOfSeasons: numberOfSeasons,
|
||||
}
|
||||
res := db.Create(&content)
|
||||
if res.Error != nil {
|
||||
// Error if anything but unique contraint error
|
||||
if !strings.Contains(res.Error.Error(), "UNIQUE") {
|
||||
println("Error creating content in database:", res.Error.Error())
|
||||
return Watched{}, errors.New("failed to cache content in database")
|
||||
}
|
||||
}
|
||||
// If row created, download the image
|
||||
if res.RowsAffected > 0 {
|
||||
err := download("https://image.tmdb.org/t/p/w500"+posterPath, path.Join("./data/img", posterPath))
|
||||
if err != nil {
|
||||
println("Failed to download content image!", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
// Error if content has no id
|
||||
if content.ID == 0 {
|
||||
return Watched{}, errors.New("failed to find content id")
|
||||
}
|
||||
// Create watched entry in db
|
||||
if ar.Status == "" {
|
||||
ar.Status = WATCHING
|
||||
}
|
||||
watched := Watched{Status: ar.Status, Rating: ar.Rating, UserID: userId, ContentID: id}
|
||||
res = db.Create(&watched)
|
||||
watched := Watched{Status: ar.Status, Rating: ar.Rating, UserID: userId, ContentID: content.ID}
|
||||
res := db.Create(&watched)
|
||||
if res.Error != nil {
|
||||
if strings.Contains(res.Error.Error(), "UNIQUE") {
|
||||
return Watched{}, errors.New("content already on watched list")
|
||||
res = db.Model(&Watched{}).Unscoped().Preload("Activity").Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Take(&watched)
|
||||
if res.Error != nil {
|
||||
return Watched{}, errors.New("content already on watched list. errored checking for soft deleted record")
|
||||
}
|
||||
if watched.DeletedAt.Time.IsZero() {
|
||||
return Watched{}, errors.New("content already on watched list")
|
||||
} else {
|
||||
println("Watched list item exists as soft deleted record.. attempting to restore")
|
||||
res = db.Model(&Watched{}).Unscoped().Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Updates(map[string]interface{}{"status": ar.Status, "rating": ar.Rating, "deleted_at": nil})
|
||||
watched.Status = ar.Status
|
||||
watched.Rating = ar.Rating
|
||||
if res.Error != nil {
|
||||
return Watched{}, errors.New("content already on watched list. errored removing soft delete timestamp")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println("Error adding watched content to database:", res.Error.Error())
|
||||
return Watched{}, errors.New("failed adding content to database")
|
||||
}
|
||||
println("Error adding watched content to database:", res.Error.Error())
|
||||
return Watched{}, errors.New("failed adding content to database")
|
||||
}
|
||||
println(res.RowsAffected)
|
||||
fmt.Printf("%+v\n", watched)
|
||||
|
||||
var activity Activity
|
||||
activityJson, err := json.Marshal(map[string]interface{}{"status": ar.Status, "rating": ar.Rating})
|
||||
if err != nil {
|
||||
println("Failed to marshal json for data in ADD_WATCHED activity request, adding without data", err.Error())
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: ADDED_WATCHED})
|
||||
} else {
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: ADDED_WATCHED, Data: string(activityJson)})
|
||||
}
|
||||
watched.Activity = append(watched.Activity, activity)
|
||||
watched.Content = content
|
||||
return watched, nil
|
||||
}
|
||||
|
||||
func updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (bool, error) {
|
||||
func updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (WatchedUpdateResponse, error) {
|
||||
println("UpdateWatched", ar.Rating, ar.Status)
|
||||
res := db.Model(&Watched{}).Where("id = ? AND user_id = ?", id, userId).Updates(Watched{Rating: ar.Rating, Status: ar.Status})
|
||||
if res.Error != nil {
|
||||
println("Watched entry update failed:", id, res.Error.Error())
|
||||
return false, errors.New("failed to update watched entry")
|
||||
return WatchedUpdateResponse{}, errors.New("failed to update watched entry")
|
||||
}
|
||||
if res.RowsAffected <= 0 {
|
||||
return false, errors.New("no watched entry found")
|
||||
return WatchedUpdateResponse{}, errors.New("no watched entry found")
|
||||
}
|
||||
return true, nil
|
||||
addedActivity := Activity{}
|
||||
if ar.Rating != 0 {
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: id, Type: RATING_CHANGED, Data: strconv.Itoa(int(ar.Rating))})
|
||||
}
|
||||
if ar.Status != "" {
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: id, Type: STATUS_CHANGED, Data: string(ar.Status)})
|
||||
}
|
||||
return WatchedUpdateResponse{NewActivity: addedActivity}, nil
|
||||
}
|
||||
|
||||
func removeWatched(db *gorm.DB, userId uint, id uint) (WatchedRemoveResponse, error) {
|
||||
println("Removing watched item:", id, "for user", userId)
|
||||
// Our model has a deleted_at field, which will make gorm do a soft delete.
|
||||
// Since other tables (eg activities) will link their rows to a watched_id, it's best to soft
|
||||
// delete, so if user restores watched item they still have activity for example (also so
|
||||
// someone else wont get other users activity if auto increment gives them the same watched id).
|
||||
res := db.Model(&Watched{}).Where("id = ? AND user_id = ?", id, userId).Delete(&Watched{})
|
||||
if res.Error != nil {
|
||||
println("Removing watched entry failed:", id, res.Error.Error())
|
||||
return WatchedRemoveResponse{}, errors.New("failed to remove watched entry")
|
||||
}
|
||||
if res.RowsAffected <= 0 {
|
||||
return WatchedRemoveResponse{}, errors.New("no watched entry found")
|
||||
}
|
||||
addedActivity, _ := addActivity(db, userId, ActivityAddRequest{WatchedID: id, Type: REMOVED_WATCHED})
|
||||
return WatchedRemoveResponse{NewActivity: addedActivity}, nil
|
||||
}
|
||||
|
||||
func download(url string, outf string) (err error) {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import type { Activity } from "@/types";
|
||||
import { getOrdinalSuffix, months } from "./util/helpers";
|
||||
|
||||
export let activity: Activity[] | undefined;
|
||||
|
||||
let groupedActivities: { [index: string]: any };
|
||||
|
||||
$: {
|
||||
groupedActivities = getGroupedActivity(activity);
|
||||
}
|
||||
|
||||
function getMsg(a: Activity) {
|
||||
switch (a?.type) {
|
||||
case "ADDED_WATCHED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `added to watched list${data?.status ? ` as ${data.status?.toLowerCase()}` : ""}${
|
||||
data?.rating ? ` with ${data.rating} stars` : ""
|
||||
}`;
|
||||
}
|
||||
return "added to watched list";
|
||||
case "REMOVED_WATCHED":
|
||||
return "removed from watched list";
|
||||
case "RATING_CHANGED":
|
||||
if (a.data) {
|
||||
return `rating changed to ${a.data}`;
|
||||
}
|
||||
return "rating changed";
|
||||
case "STATUS_CHANGED":
|
||||
if (a.data) {
|
||||
return `status changed to ${a.data?.toLowerCase()}`;
|
||||
}
|
||||
return "status changed";
|
||||
default:
|
||||
return a.type;
|
||||
}
|
||||
}
|
||||
|
||||
function toDayTime(d: Date) {
|
||||
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} at ${d.toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
function getGroupedActivity(activities?: Activity[]) {
|
||||
activities = activities?.filter((a) => a.type);
|
||||
const a = activities?.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
||||
let grouped: { [index: string]: any } = {};
|
||||
if (a) {
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const activity = a[i];
|
||||
const date = new Date(Date.parse(activity.createdAt));
|
||||
const key = `${months[date.getMonth()]} ${date.getFullYear()}`;
|
||||
if (grouped[key]) {
|
||||
grouped[key].push(activity);
|
||||
} else {
|
||||
grouped[key] = [activity];
|
||||
}
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="activity">
|
||||
<h2>Activity</h2>
|
||||
{#if groupedActivities && Object.keys(groupedActivities).length > 0}
|
||||
<ul>
|
||||
{#each Object.keys(groupedActivities) as k}
|
||||
<h3>{k}</h3>
|
||||
|
||||
{#each groupedActivities[k] as a}
|
||||
<li>
|
||||
<span title={new Date(a.createdAt).toDateString()}>
|
||||
{toDayTime(new Date(a.createdAt))}
|
||||
</span>
|
||||
<span>{getMsg(a)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<span>You Have No Activity!</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.activity {
|
||||
width: 100%;
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
margin-left: calc(30px + 8px);
|
||||
list-style: none;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
h3 {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
bottom: 100px;
|
||||
background-color: white;
|
||||
font-size: 16px;
|
||||
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
|
||||
span {
|
||||
width: max-content;
|
||||
margin-left: 0px;
|
||||
|
||||
&:first-child {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
background-color: rgb(46, 46, 46);
|
||||
text-transform: capitalize;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-left: calc(30px);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-left: 30px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
export let pretty: string;
|
||||
export let error: any;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<strong>{pretty}</strong>
|
||||
{#if error?.message}
|
||||
<p>{error.message}</p>
|
||||
{#if error.response?.data?.error}
|
||||
<p>{error.response.data.error}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p>{JSON.stringify(error)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
max-width: 500px;
|
||||
gap: 5px;
|
||||
text-transform: capitalize;
|
||||
font-size: 16px;
|
||||
|
||||
p {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
export let title: string | undefined;
|
||||
</script>
|
||||
|
||||
{#if title}
|
||||
<h2>{title}</h2>
|
||||
{/if}
|
||||
<ul>
|
||||
<slot />
|
||||
</ul>
|
||||
|
||||
<style lang="scss">
|
||||
h2 {
|
||||
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-left: 30px;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
list-style: none;
|
||||
padding: 15px 30px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
+78
-7
@@ -1,12 +1,11 @@
|
||||
<script lang="ts" context="module">
|
||||
export type Icon = "check" | "clock" | "calendar";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Icon } from "@/types";
|
||||
|
||||
export let i: Icon;
|
||||
export let wh = 24;
|
||||
</script>
|
||||
|
||||
{#if i == "check"}
|
||||
{#if i === "check"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
fill="none"
|
||||
@@ -17,16 +16,88 @@
|
||||
d="M416 128L192 384l-96-96"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i == "clock"}
|
||||
{:else if i === "clock"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M256 48C141.13 48 48 141.13 48 256c0 114.69 93.32 208 208 208 114.86 0 208-93.14 208-208 0-114.69-93.31-208-208-208zm108 240H244a4 4 0 01-4-4V116a4 4 0 014-4h24a4 4 0 014 4v140h92a4 4 0 014 4v24a4 4 0 01-4 4z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i == "calendar"}
|
||||
{:else if i === "play"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"
|
||||
><path
|
||||
d="M133 440a35.37 35.37 0 01-17.5-4.67c-12-6.8-19.46-20-19.46-34.33V111c0-14.37 7.46-27.53 19.46-34.33a35.13 35.13 0 0135.77.45l247.85 148.36a36 36 0 010 61l-247.89 148.4A35.5 35.5 0 01133 440z"
|
||||
/></svg
|
||||
>
|
||||
{:else if i === "calendar"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M32 456a24 24 0 0024 24h400a24 24 0 0024-24V176H32zm80-238.86a9.14 9.14 0 019.14-9.14h109.72a9.14 9.14 0 019.14 9.14v109.72a9.14 9.14 0 01-9.14 9.14H121.14a9.14 9.14 0 01-9.14-9.14zM456 64h-55.92V32h-48v32H159.92V32h-48v32H56a23.8 23.8 0 00-24 23.77V144h448V87.77A23.8 23.8 0 00456 64z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "thumb-down"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"
|
||||
><path
|
||||
d="M39.94 178l144.16 6.12c4.61.36 23.9 1.22 23.9 25.88 0 23.8-19.16 25.33-24.14 25.88L39.94 242C27.27 241.87 16 227.56 16 210s11.27-31.87 23.94-32zm141.45 131.66L74.65 318C62 318 48 301.31 48 284.12v-.33c0-16.33 11.14-29.63 24.88-29.79l108.45 1.72C208 259 208 275.16 208 282.12c0 22.88-21.8 27.14-26.61 27.54zM90.15 32l89.37 8.93C204 41.86 208 58.18 208 68.4c0 18.39-13.41 24.6-26.67 24.6l-91 3C75.78 95.78 64 81.51 64 64s11.68-31.66 26.15-32zm-34.36 71.5l126.4 6.22c9.39.63 25.81 3 25.81 26.36 0 12-4.35 25.62-25 27.53L55.79 167.5C42.65 167.35 32 154 32 136.08s10.65-32.43 23.79-32.58z"
|
||||
/><path
|
||||
d="M378.45 273.93A15.84 15.84 0 01386 272a15.93 15.93 0 00-7.51 1.91zM337.86 343.22l-.13.22a2.53 2.53 0 01.13-.22c20.5-35.51 30.36-55 33.82-62-3.47 7.06-13.34 26.51-33.82 62z"
|
||||
fill="none"
|
||||
/><path d="M372.66 279.16l-1 2a16.29 16.29 0 016.77-7.26 16.48 16.48 0 00-5.77 5.26z" /><path
|
||||
d="M195.94 459.38C205.37 472.67 221 480 240 480a16 16 0 0014.31-8.85c3-6.06 15.25-24 28.19-42.9 18-26.33 40.35-59.08 55.23-84.81l.13-.22c20.48-35.49 30.35-54.94 33.82-62l1-2a16.48 16.48 0 015.79-5.23A15.93 15.93 0 01386 272h25.32A84.7 84.7 0 00496 187.3v-38.6A84.7 84.7 0 00411.31 64h-48.79a17.46 17.46 0 01-9.58-2.89C330 46.13 286.66 32 240 32c-7.45 0-14.19.14-20.27.38a8 8 0 00-6.2 12.68l.1.14C222.2 57.59 224 71 224 80a61.16 61.16 0 01-5.19 24.77 17.38 17.38 0 000 14.06 63.81 63.81 0 010 50.39 17.32 17.32 0 000 14 62.13 62.13 0 010 49.58 18.13 18.13 0 000 14.68A60.41 60.41 0 01224 273c0 8.2-2 21.3-8 31.18a15.66 15.66 0 00-1.14 13.65c.38 1 .76 2.06 1.13 3.17a24.8 24.8 0 01.86 11.57c-3 19.35-9.67 36.3-16.74 54.16-3.08 7.78-6.27 15.82-9.22 24.27-6.14 17.56-4.3 35.2 5.05 48.38z"
|
||||
/></svg
|
||||
>
|
||||
{:else if i === "pause"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm-32 272a16 16 0 01-32 0V192a16 16 0 0132 0zm96 0a16 16 0 01-32 0V192a16 16 0 0132 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "jellyfin"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={wh} height={wh}>
|
||||
<path
|
||||
d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0C25.384 18.573 15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189.518 1.04 7.348 1.027 7.86 0 .511-1.027-2.874-7.19-3.93-7.19z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "trash"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M112 112l20 320c.95 18.49 14.4 32 32 32h184c17.67 0 30.87-13.51 32-32l20-320"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="32"
|
||||
d="M80 112h352"
|
||||
/>
|
||||
<path
|
||||
d="M192 112V72h0a23.93 23.93 0 0124-24h80a23.93 23.93 0 0124 24h0v40M256 176v224M184 176l8 224M328 176l-8 224"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "close"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
d="M368 368L144 144M368 144L144 368"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "filter"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M472 168H40a24 24 0 010-48h432a24 24 0 010 48zM392 280H120a24 24 0 010-48h272a24 24 0 010 48zM296 392h-80a24 24 0 010-48h80a24 24 0 010 48z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
export let pretty: string;
|
||||
export let error: any;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<strong>{pretty}</strong>
|
||||
{#if error?.message}
|
||||
<p>{error.message}</p>
|
||||
{#if error.response}
|
||||
<p>{error.response.data.error}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p>{JSON.stringify(error)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
max-width: 500px;
|
||||
gap: 15px;
|
||||
|
||||
p {
|
||||
color: white;
|
||||
background-color: rgb(66, 60, 60);
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { addClassToParent, calculateTransformOrigin } from "@/lib/util/helpers";
|
||||
|
||||
export let id: number | undefined;
|
||||
export let name: string | undefined;
|
||||
export let path: string | undefined;
|
||||
export let role: string | undefined = undefined;
|
||||
export let zoomOnHover: boolean = true;
|
||||
|
||||
const poster = path ? `https://image.tmdb.org/t/p/w300_and_h450_bestv2${path}` : undefined;
|
||||
const link = id ? `/person/${id}` : undefined;
|
||||
</script>
|
||||
|
||||
<li
|
||||
on:mouseenter={(e) => calculateTransformOrigin(e)}
|
||||
on:focusin={(e) => calculateTransformOrigin(e)}
|
||||
on:click={() => {
|
||||
if (link) goto(link);
|
||||
}}
|
||||
on:keypress={() => console.log("on kpress")}
|
||||
>
|
||||
<div class={`container${!poster ? " details-shown" : ""}${!zoomOnHover ? " no-zoom" : ""}`}>
|
||||
{#if poster}
|
||||
<div class="img-loader" />
|
||||
<img
|
||||
loading="lazy"
|
||||
src={poster}
|
||||
alt=""
|
||||
on:load={(e) => {
|
||||
addClassToParent(e, "img-loaded");
|
||||
}}
|
||||
on:error={(e) => {
|
||||
console.log("on err");
|
||||
addClassToParent(e, "details-shown");
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<div class="inner">
|
||||
<h2>
|
||||
{#if link}
|
||||
<a data-sveltekit-preload-data="tap" href={link}>
|
||||
{name}
|
||||
</a>
|
||||
{:else}
|
||||
{name}
|
||||
{/if}
|
||||
</h2>
|
||||
{#if role}
|
||||
<h3>{role}</h3>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<style lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
background-color: rgb(48, 45, 45);
|
||||
overflow: hidden;
|
||||
flex: 1 1;
|
||||
border-radius: 5px;
|
||||
width: 170px;
|
||||
height: 100%;
|
||||
min-height: 256.367px;
|
||||
position: relative;
|
||||
transition: transform 150ms ease, outline 50ms ease;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.img-loaded .img-loader,
|
||||
&.details-shown .img-loader {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.img-loader {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: gray;
|
||||
background: linear-gradient(359deg, #5c5c5c, #2c2929, #2c2424);
|
||||
background-size: 400% 400%;
|
||||
animation: imgloader 4s ease infinite;
|
||||
|
||||
@-webkit-keyframes imgloader {
|
||||
0% {
|
||||
background-position: 50% 0%;
|
||||
}
|
||||
50% {
|
||||
background-position: 50% 100%;
|
||||
}
|
||||
100% {
|
||||
background-position: 50% 0%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inner {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: transparent;
|
||||
|
||||
h2,
|
||||
h3 {
|
||||
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-size: 18px;
|
||||
color: white;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: auto;
|
||||
text-shadow: 1px 1px 3px black;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 12px;
|
||||
text-shadow: 0px 1px 1px black;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.no-zoom) {
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
transform: scale(1.3);
|
||||
z-index: 99;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:not(.no-zoom)) {
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
outline: 3px solid black;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within,
|
||||
&:global(.details-shown) {
|
||||
.inner {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+281
-78
@@ -1,61 +1,182 @@
|
||||
<script lang="ts">
|
||||
import type { Rating, WatchedStatus } from "@/types";
|
||||
import type { MediaType, WatchedStatus } from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
import {
|
||||
addClassToParent,
|
||||
calculateTransformOrigin,
|
||||
isTouch,
|
||||
watchedStatuses
|
||||
} from "@/lib/util/helpers";
|
||||
import { goto } from "$app/navigation";
|
||||
import tooltip from "./actions/tooltip";
|
||||
|
||||
export let poster: string | undefined;
|
||||
export let title: string | undefined;
|
||||
export let desc: string | undefined;
|
||||
export let rating: Rating = undefined;
|
||||
export let media: {
|
||||
poster_path?: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
overview?: string;
|
||||
id?: number;
|
||||
media_type?: MediaType;
|
||||
};
|
||||
export let rating: number | undefined = undefined;
|
||||
export let status: WatchedStatus | undefined = undefined;
|
||||
export let onBtnClicked: (type: WatchedStatus, rating?: Rating) => void = () => {};
|
||||
export let onRatingChanged: (rating: Rating) => void = () => {};
|
||||
export let onStatusChanged: (type: WatchedStatus) => void = () => {};
|
||||
export let onRatingChanged: (rating: number) => void = () => {};
|
||||
export let onDeleteClicked: () => void = () => {};
|
||||
|
||||
let ratingContainer: HTMLDivElement;
|
||||
// If poster is active (scaled up)
|
||||
let posterActive = false;
|
||||
// If ratings are shown
|
||||
let ratingsShown = false;
|
||||
// If statuses are shown
|
||||
let statusesShown = false;
|
||||
|
||||
function handleStarClick(r: Rating) {
|
||||
const title = media.title || media.name;
|
||||
const poster = `https://image.tmdb.org/t/p/w500${media.poster_path}`;
|
||||
const link = media.id ? `/${media.media_type}/${media.id}` : undefined;
|
||||
|
||||
function handleStarClick(r: number) {
|
||||
if (r == rating) return;
|
||||
onRatingChanged(r);
|
||||
ratingsShown = false;
|
||||
}
|
||||
|
||||
function wBtnClicked(type: WatchedStatus) {
|
||||
onBtnClicked(type, rating);
|
||||
function handleStatusClick(type: WatchedStatus | "DELETE") {
|
||||
if (type === "DELETE") {
|
||||
onDeleteClicked();
|
||||
return;
|
||||
}
|
||||
onStatusChanged(type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<li>
|
||||
<div class="container">
|
||||
<img loading="lazy" src={poster} alt="poster" />
|
||||
<div class="inner">
|
||||
<h2>{title}</h2>
|
||||
<span>{desc}</span>
|
||||
<li
|
||||
on:mouseenter={(e) => {
|
||||
if (!posterActive) calculateTransformOrigin(e);
|
||||
if (!isTouch()) {
|
||||
posterActive = true;
|
||||
}
|
||||
}}
|
||||
on:focusin={(e) => {
|
||||
if (!posterActive) calculateTransformOrigin(e);
|
||||
}}
|
||||
on:mouseleave={() => (posterActive = false)}
|
||||
on:click={() => (posterActive = true)}
|
||||
on:keypress={() => console.log("on kpress")}
|
||||
class={posterActive ? "active" : ""}
|
||||
>
|
||||
<div class={`container${!poster ? " details-shown" : ""}`}>
|
||||
{#if poster}
|
||||
<div class="img-loader" />
|
||||
<img
|
||||
loading="lazy"
|
||||
src={poster}
|
||||
alt=""
|
||||
on:load={(e) => {
|
||||
addClassToParent(e, "img-loaded");
|
||||
}}
|
||||
on:error={(e) => {
|
||||
addClassToParent(e, "details-shown");
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<div
|
||||
on:click={() => {
|
||||
if (posterActive && link) goto(link);
|
||||
}}
|
||||
on:keypress={() => console.log("on kpress")}
|
||||
class="inner"
|
||||
>
|
||||
<h2>
|
||||
{#if link}
|
||||
<a data-sveltekit-preload-data="tap" href={link}>
|
||||
{title}
|
||||
</a>
|
||||
{:else}
|
||||
{title}
|
||||
{/if}
|
||||
</h2>
|
||||
<span>{media.overview}</span>
|
||||
|
||||
<div id="rating-container" class="rating" bind:this={ratingContainer}>
|
||||
<button class="plain{rating === 5 ? ' lit' : ''}" on:click={() => handleStarClick(5)}>*</button>
|
||||
<button class="plain{rating === 4 ? ' lit' : ''}" on:click={() => handleStarClick(4)}>*</button>
|
||||
<button class="plain{rating === 3 ? ' lit' : ''}" on:click={() => handleStarClick(3)}>*</button>
|
||||
<button class="plain{rating === 2 ? ' lit' : ''}" on:click={() => handleStarClick(2)}>*</button>
|
||||
<button class="plain{rating === 1 ? ' lit' : ''}" on:click={() => handleStarClick(1)}>*</button>
|
||||
</div>
|
||||
|
||||
<div class="btn-container">
|
||||
<div class="buttons">
|
||||
<button
|
||||
class={status && status !== "PLANNED" ? "not-active" : ""}
|
||||
on:click={() => wBtnClicked("PLANNED")}><Icon i="calendar" /></button
|
||||
class="rating"
|
||||
on:click={(ev) => {
|
||||
ev.stopPropagation();
|
||||
ratingsShown = !ratingsShown;
|
||||
}}
|
||||
on:mouseleave={(ev) => {
|
||||
ratingsShown = false;
|
||||
ev.currentTarget.blur();
|
||||
}}
|
||||
>
|
||||
<span>*</span>
|
||||
<span>{rating ? rating : "Rate"}</span>
|
||||
{#if ratingsShown}
|
||||
<div>
|
||||
{#each [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] as v}
|
||||
<button
|
||||
class="plain{rating === v ? ' active' : ''}"
|
||||
on:click={(ev) => {
|
||||
ev.stopPropagation();
|
||||
handleStarClick(v);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "WATCHING" ? "not-active" : ""}
|
||||
on:click={() => wBtnClicked("WATCHING")}><Icon i="clock" /></button
|
||||
>
|
||||
<button
|
||||
class={status && status !== "FINISHED" ? "not-active" : ""}
|
||||
on:click={() => wBtnClicked("FINISHED")}><Icon i="check" /></button
|
||||
class="status"
|
||||
on:click={(ev) => {
|
||||
ev.stopPropagation();
|
||||
statusesShown = !statusesShown;
|
||||
}}
|
||||
on:mouseleave={(ev) => {
|
||||
statusesShown = false;
|
||||
ev.currentTarget.blur();
|
||||
}}
|
||||
>
|
||||
{#if status}
|
||||
<Icon i={watchedStatuses[status]} />
|
||||
{:else}
|
||||
<span class="no-icon">+</span>
|
||||
{/if}
|
||||
{#if statusesShown}
|
||||
<div>
|
||||
{#each Object.entries(watchedStatuses) as [statusName, icon]}
|
||||
<button
|
||||
class="plain{status && status !== statusName ? ' not-active' : ''}"
|
||||
on:click={() => handleStatusClick(statusName)}
|
||||
use:tooltip={{ text: statusName }}
|
||||
>
|
||||
<Icon i={icon} />
|
||||
</button>
|
||||
{/each}
|
||||
{#if status}
|
||||
<button
|
||||
class="plain not-active"
|
||||
on:click={() => handleStatusClick("DELETE")}
|
||||
use:tooltip={{ text: "Delete" }}
|
||||
>
|
||||
<Icon i="trash" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<style lang="scss">
|
||||
li.active {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
@@ -65,13 +186,46 @@
|
||||
border-radius: 5px;
|
||||
width: 170px;
|
||||
height: 100%;
|
||||
min-height: 256.367px;
|
||||
position: relative;
|
||||
// aspect-ratio: 2/3;
|
||||
transition: all 150ms ease-in;
|
||||
transition: transform 150ms ease;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.img-loaded .img-loader,
|
||||
&.details-shown .img-loader {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.img-loader {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: gray;
|
||||
background: linear-gradient(359deg, #5c5c5c, #2c2929, #2c2424);
|
||||
background-size: 400% 400%;
|
||||
animation: imgloader 4s ease infinite;
|
||||
|
||||
@-webkit-keyframes imgloader {
|
||||
0% {
|
||||
background-position: 50% 0%;
|
||||
}
|
||||
50% {
|
||||
background-position: 50% 100%;
|
||||
}
|
||||
100% {
|
||||
background-position: 50% 0%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inner {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
top: 0;
|
||||
@@ -79,10 +233,13 @@
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: transparent;
|
||||
transition: opacity 150ms cubic-bezier(0.19, 1, 0.22, 1);
|
||||
|
||||
h2 {
|
||||
font-family: unset;
|
||||
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-size: 18px;
|
||||
color: white;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
span {
|
||||
@@ -96,61 +253,107 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
flex-flow: row-reverse;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
-webkit-text-stroke: 1.5px white;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
margin: 10px 0 10px 0;
|
||||
|
||||
button {
|
||||
font-size: 35px;
|
||||
font-family: "Rampart One";
|
||||
letter-spacing: 10px;
|
||||
line-height: 52px;
|
||||
height: 38px;
|
||||
|
||||
&:hover,
|
||||
&:hover ~ button,
|
||||
&:global(.lit),
|
||||
&:global(.lit ~ button) {
|
||||
color: gold;
|
||||
-webkit-text-stroke: 1.5px gold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-container {
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
gap: 10px;
|
||||
height: 35px;
|
||||
|
||||
button {
|
||||
font-size: 10px;
|
||||
padding: 3px;
|
||||
position: relative;
|
||||
font-family: "Rampart One";
|
||||
|
||||
&:hover {
|
||||
fill: white;
|
||||
/** Rating */
|
||||
&.rating {
|
||||
span {
|
||||
color: black;
|
||||
/* -webkit-text-stroke: 1.5px black; */
|
||||
|
||||
&:first-child {
|
||||
font-size: 39px;
|
||||
letter-spacing: 10px;
|
||||
line-height: 52px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
font-size: 22px;
|
||||
height: 35px; // quick fix to make the rating num look centered - text-stroke makes it look not centered
|
||||
}
|
||||
}
|
||||
|
||||
&:hover span,
|
||||
&:focus-visible span {
|
||||
color: gold;
|
||||
/* -webkit-text-stroke: 1.5px gold; */
|
||||
}
|
||||
|
||||
div button {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/** Status */
|
||||
&.status {
|
||||
width: 40%;
|
||||
|
||||
.no-icon {
|
||||
color: black;
|
||||
font-size: 30px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
&:hover .no-icon,
|
||||
&:focus-visible .no-icon {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background-color: white;
|
||||
top: calc(-100% - 170px);
|
||||
list-style: none;
|
||||
border-radius: 4px 4px 0 0;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
color: black;
|
||||
fill: black;
|
||||
|
||||
& :global(svg) {
|
||||
width: 100%;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: rgb(100, 100, 100, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
transform: scale(1.3);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
z-index: 99;
|
||||
|
||||
&:hover,
|
||||
&:focus-within,
|
||||
&:global(.details-shown) {
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(4px) grayscale(80%);
|
||||
// This makes the background very dark,
|
||||
// but atleast the text is visible.. may want to change later.
|
||||
@@ -159,7 +362,7 @@
|
||||
|
||||
.inner {
|
||||
color: white;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
gap: 10px;
|
||||
list-style: none;
|
||||
flex-wrap: wrap;
|
||||
margin: 20px;
|
||||
margin: 20px 10px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
export let rating: number | undefined;
|
||||
export let onChange: (newRating: number) => void;
|
||||
|
||||
let hoveredRating: number | undefined;
|
||||
let shownRating: number | undefined;
|
||||
|
||||
const ratingDesc = [
|
||||
"Apalling",
|
||||
"Horrible",
|
||||
"Very Bad",
|
||||
"Bad",
|
||||
"Average",
|
||||
"Fine",
|
||||
"Good",
|
||||
"Very Good",
|
||||
"Great",
|
||||
"Masterpiece"
|
||||
];
|
||||
|
||||
function handleStarClick(r: number) {
|
||||
onChange(r);
|
||||
}
|
||||
|
||||
$: {
|
||||
if (hoveredRating !== undefined) shownRating = hoveredRating;
|
||||
else if (rating !== undefined) shownRating = rating;
|
||||
else shownRating = undefined;
|
||||
}
|
||||
|
||||
function handleStarHover(r: number) {
|
||||
hoveredRating = r;
|
||||
}
|
||||
|
||||
function handleStarHoverEnd() {
|
||||
hoveredRating = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rating-container">
|
||||
<span>
|
||||
{#if typeof hoveredRating === "number"}
|
||||
{ratingDesc[hoveredRating - 1]}
|
||||
{:else if typeof rating === "number" && rating > 0}
|
||||
{ratingDesc[rating - 1]}
|
||||
{:else}
|
||||
Select Your Rating
|
||||
{/if}
|
||||
</span>
|
||||
<div class="rating">
|
||||
{#each [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] as v}
|
||||
<button
|
||||
class="plain{shownRating === v ? ' lit' : ''}"
|
||||
on:click={() => handleStarClick(v)}
|
||||
on:mouseenter={() => handleStarHover(v)}
|
||||
on:mouseleave={() => handleStarHoverEnd()}
|
||||
>
|
||||
*
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.rating-container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
flex-flow: row-reverse;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: black;
|
||||
-webkit-text-stroke: 1.5px black;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
margin: 10px 0 10px 0;
|
||||
padding: 1px;
|
||||
|
||||
button {
|
||||
font-size: 55px;
|
||||
font-family: "Rampart One";
|
||||
letter-spacing: 10px;
|
||||
line-height: 52px;
|
||||
height: 38px;
|
||||
|
||||
&:global(.lit),
|
||||
&:global(.lit ~ button) {
|
||||
color: gold;
|
||||
-webkit-text-stroke: 1.5px gold;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 450px) {
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
font-size: 45px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,219 @@
|
||||
<script lang="ts">
|
||||
import type { TMDBSeasonDetails, TMDBShowSeason } from "@/types";
|
||||
import axios from "axios";
|
||||
import Spinner from "./Spinner.svelte";
|
||||
import Error from "./Error.svelte";
|
||||
|
||||
export let tvId: number;
|
||||
export let seasons: TMDBShowSeason[];
|
||||
|
||||
let activeSeason = 1;
|
||||
let seasonDetailsReq = sdr(activeSeason);
|
||||
let seasonsEl: HTMLUListElement, episodesEl: HTMLDivElement;
|
||||
|
||||
async function sdr(seasonNum: number) {
|
||||
return (await axios.get(`/content/tv/${tvId}/season/${seasonNum}`)).data as TMDBSeasonDetails;
|
||||
}
|
||||
|
||||
$: {
|
||||
seasonDetailsReq = sdr(activeSeason);
|
||||
}
|
||||
|
||||
// onMount(() => {
|
||||
// episodesEl.style.height = `${seasonsEl.clientHeight}px`;
|
||||
// seasonsEl.addEventListener("resize", () => {
|
||||
// console.log("resize");
|
||||
// episodesEl.style.height = `${seasonsEl.clientHeight}px`;
|
||||
// });
|
||||
// });
|
||||
</script>
|
||||
|
||||
<div class="ctr">
|
||||
<ul class="seasons" bind:this={seasonsEl}>
|
||||
<!-- {#each seasons.sort((a, b) => Date.parse(b.air_date) - Date.parse(a.air_date)) as season} -->
|
||||
{#each seasons as season}
|
||||
<button
|
||||
class={`plain${activeSeason === season.season_number ? " active" : ""}`}
|
||||
on:click={() => {
|
||||
activeSeason = season.season_number;
|
||||
}}
|
||||
>
|
||||
<h1>{season.name}</h1>
|
||||
{#if season.air_date}
|
||||
<h2>{new Date(Date.parse(season.air_date)).getFullYear()}</h2>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="last" />
|
||||
</ul>
|
||||
|
||||
<div class="episodes" bind:this={episodesEl}>
|
||||
{#await seasonDetailsReq}
|
||||
<Spinner />
|
||||
{:then season}
|
||||
{#if season}
|
||||
<ul>
|
||||
{#each season.episodes as ep}
|
||||
<li>
|
||||
{#if ep.still_path}
|
||||
<img
|
||||
src={`https://www.themoviedb.org/t/p/w227_and_h127_bestv2/${ep.still_path}`}
|
||||
alt=""
|
||||
/>
|
||||
{:else}
|
||||
<div class="no-still" />
|
||||
{/if}
|
||||
<span>
|
||||
<b>{ep.episode_number}</b>
|
||||
{ep.name}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:catch err}
|
||||
<Error pretty="Failed to load season details!" error={err} />
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.ctr {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.episodes {
|
||||
overflow: auto;
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
list-style: none;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 12px;
|
||||
|
||||
img,
|
||||
.no-still {
|
||||
width: 227px;
|
||||
min-width: 227px;
|
||||
height: 127px;
|
||||
min-height: 127px;
|
||||
border-radius: 10px;
|
||||
background-color: rgb(0, 0, 0);
|
||||
object-fit: fill;
|
||||
|
||||
@media screen and (max-width: 590px) {
|
||||
width: 80%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 450px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
padding: 8px 5px;
|
||||
|
||||
@media screen and (max-width: 590px) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 590px) {
|
||||
li {
|
||||
align-items: center;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ul.seasons {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
list-style: none;
|
||||
gap: 8px;
|
||||
min-width: fit-content;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 2px solid #302d2d;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 100ms ease;
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 12px;
|
||||
font-family: sans-serif;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
color: white;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
&.active {
|
||||
position: sticky;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* hack to get extra scroll space under last el */
|
||||
.last {
|
||||
padding: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 960px) {
|
||||
.ctr {
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
ul.seasons {
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
position: unset;
|
||||
height: unset;
|
||||
justify-content: center;
|
||||
|
||||
button {
|
||||
&:first-of-type {
|
||||
margin-top: unset;
|
||||
}
|
||||
&.active {
|
||||
position: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<div class="container">
|
||||
<div class="spinner" />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 80px;
|
||||
aspect-ratio: 1;
|
||||
border: 10px solid #0000;
|
||||
background: radial-gradient(farthest-side, #000 98%, #fff) 0 0/20px 20px,
|
||||
radial-gradient(farthest-side, #000 98%, #fff) 100% 0/20px 20px,
|
||||
radial-gradient(farthest-side, #000 98%, #fff) 100% 100%/20px 20px,
|
||||
radial-gradient(farthest-side, #000 98%, #fff) 0 100%/20px 20px,
|
||||
linear-gradient(#000 0 0) center/40px 40px, #fff;
|
||||
background-repeat: no-repeat;
|
||||
filter: blur(4px) contrast(10);
|
||||
animation: spin 800ms infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
100% {
|
||||
background-position: 100% 0, 100% 100%, 0 100%, 0 0, center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import Icon from "./Icon.svelte";
|
||||
import type { WatchedStatus } from "../types";
|
||||
import tooltip from "./actions/tooltip";
|
||||
|
||||
export let status: WatchedStatus | undefined;
|
||||
export let onChange: (newStatus: WatchedStatus) => void;
|
||||
</script>
|
||||
|
||||
<div class="status">
|
||||
<button
|
||||
class={status && status !== "PLANNED" ? "not-active" : ""}
|
||||
on:click={() => onChange("PLANNED")}
|
||||
use:tooltip={{ text: "Planned", pos: "top" }}
|
||||
>
|
||||
<Icon i="calendar" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "WATCHING" ? "not-active" : ""}
|
||||
on:click={() => onChange("WATCHING")}
|
||||
use:tooltip={{ text: "Watching", pos: "top" }}
|
||||
>
|
||||
<Icon i="clock" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "FINISHED" ? "not-active" : ""}
|
||||
on:click={() => onChange("FINISHED")}
|
||||
use:tooltip={{ text: "Finished", pos: "top" }}
|
||||
>
|
||||
<Icon i="check" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "HOLD" ? "not-active" : ""}
|
||||
on:click={() => onChange("HOLD")}
|
||||
use:tooltip={{ text: "On Hold", pos: "top" }}
|
||||
>
|
||||
<Icon i="pause" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "DROPPED" ? "not-active" : ""}
|
||||
on:click={() => onChange("DROPPED")}
|
||||
use:tooltip={{ text: "Dropped", pos: "top" }}
|
||||
>
|
||||
<Icon i="thumb-down" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.status {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
button {
|
||||
font-size: 10px;
|
||||
|
||||
&:hover {
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
export interface ToolTipOptions {
|
||||
text: string;
|
||||
pos?: "left" | "top";
|
||||
}
|
||||
|
||||
export default function tooltip(node: HTMLElement, opts: ToolTipOptions) {
|
||||
const { text, pos = "left" } = opts;
|
||||
const tooltip = document.getElementById("tooltip");
|
||||
|
||||
const show = () => {
|
||||
if (tooltip) {
|
||||
tooltip.innerHTML = text;
|
||||
const nrect = node.getBoundingClientRect();
|
||||
const trect = tooltip.getBoundingClientRect();
|
||||
nrect.y += window.scrollY; // Add scrollY to node dom rect so tooltip shows correcting when page is scrolled down
|
||||
if (pos === "left") {
|
||||
tooltip.style.left = `${nrect.x - trect.width - 10}px`;
|
||||
tooltip.style.top = `${nrect.y + trect.height / 2 - 10}px`;
|
||||
} else if (pos === "top") {
|
||||
tooltip.style.left = `${nrect.x - trect.width / 2 + nrect.width / 2}px`;
|
||||
tooltip.style.top = `${nrect.y - trect.height - 5}px`;
|
||||
}
|
||||
tooltip.style.visibility = "visible";
|
||||
}
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
if (tooltip) {
|
||||
tooltip.style.visibility = "hidden";
|
||||
}
|
||||
};
|
||||
|
||||
node.addEventListener("mouseover", show);
|
||||
node.addEventListener("touchstart", show);
|
||||
node.addEventListener("mouseout", hide);
|
||||
node.addEventListener("touchend", hide);
|
||||
node.addEventListener("click", hide);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener("mouseover", show);
|
||||
node.removeEventListener("touchstart", show);
|
||||
node.removeEventListener("mouseout", hide);
|
||||
node.removeEventListener("touchend", hide);
|
||||
node.removeEventListener("click", hide);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { Rating, WatchedStatus, WatchedUpdateRequest } from "@/types";
|
||||
import axios from "axios";
|
||||
|
||||
export function updateWatched(id: number, status?: WatchedStatus, rating?: Rating) {
|
||||
if (!status && !rating) return;
|
||||
const obj = {} as WatchedUpdateRequest;
|
||||
if (status) obj.status = status;
|
||||
if (rating) obj.rating = rating;
|
||||
return axios.put(`/watched/${id}`, obj);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { watchedList } from "@/store";
|
||||
import type {
|
||||
MediaType,
|
||||
Watched,
|
||||
WatchedAddRequest,
|
||||
WatchedStatus,
|
||||
WatchedUpdateRequest
|
||||
} from "@/types";
|
||||
import axios from "axios";
|
||||
import { get } from "svelte/store";
|
||||
import { notify } from "./notify";
|
||||
const { MODE } = import.meta.env;
|
||||
|
||||
export const baseURL = MODE === "development" ? "http://127.0.0.1:3080/api" : "/api";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param contentId TMDB ID
|
||||
* @param contentType
|
||||
* @param status
|
||||
* @param rating
|
||||
* @returns
|
||||
*/
|
||||
export function updateWatched(
|
||||
contentId: number,
|
||||
contentType: MediaType,
|
||||
status?: WatchedStatus,
|
||||
rating?: number
|
||||
) {
|
||||
// If item is already in watched store, run update request instead
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find(
|
||||
(w) => w.content.tmdbId === contentId && w.content.type === contentType
|
||||
);
|
||||
if (wEntry?.id) {
|
||||
if (!status && !rating) return;
|
||||
const obj = {} as WatchedUpdateRequest;
|
||||
if (status) obj.status = status;
|
||||
if (rating) obj.rating = rating;
|
||||
axios
|
||||
.put(`/watched/${wEntry.id}`, obj)
|
||||
.then((resp) => {
|
||||
if (status) wEntry.status = status;
|
||||
if (rating) wEntry.rating = rating;
|
||||
if (resp?.data?.newActivity) {
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(resp.data.newActivity);
|
||||
} else {
|
||||
wEntry.activity = [resp.data.newActivity];
|
||||
}
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ text: "Failed To Update!", type: "error" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Add new watched item
|
||||
axios
|
||||
.post("/watched", {
|
||||
contentId,
|
||||
contentType,
|
||||
rating,
|
||||
status
|
||||
} as WatchedAddRequest)
|
||||
.then((resp) => {
|
||||
console.log("Added watched:", resp.data);
|
||||
wList.push(resp.data as Watched);
|
||||
watchedList.update(() => wList);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ text: "Failed To Add!", type: "error" });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an item from watched list.
|
||||
* @param id Watched Entry ID
|
||||
*/
|
||||
export function removeWatched(id: number) {
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find((w) => w.id === id);
|
||||
if (!wEntry) {
|
||||
console.log("Watched entry does not exist!");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.delete(`/watched/${id}`)
|
||||
.then((resp) => {
|
||||
console.log("Removed watched:", resp.data);
|
||||
const newList = wList.filter((w) => w.id !== id);
|
||||
watchedList.update(() => newList);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ text: "Failed To Remove!", type: "error" });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* For use with routes that don't require authentication (eg login/register)
|
||||
*/
|
||||
export const noAuthAxios = axios.create({
|
||||
baseURL: baseURL
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Icon, MediaType, TMDBContentCreditsCrew, Watched, WatchedStatus } from "@/types";
|
||||
|
||||
export const watchedStatuses: {
|
||||
[key in WatchedStatus]: Icon;
|
||||
} = {
|
||||
PLANNED: "calendar",
|
||||
WATCHING: "clock",
|
||||
FINISHED: "check",
|
||||
HOLD: "pause",
|
||||
DROPPED: "thumb-down"
|
||||
};
|
||||
|
||||
export const months = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December"
|
||||
];
|
||||
|
||||
export const monthsShort = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"March",
|
||||
"Apr",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"Aug",
|
||||
"Sept",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec"
|
||||
];
|
||||
|
||||
export function isTouch() {
|
||||
return "ontouchstart" in window;
|
||||
}
|
||||
|
||||
// Not passing wList from #each loop caused it not to have reactivity.
|
||||
// Passing it through must allow it to recognize it as a dependency?
|
||||
export function getWatchedDependedProps(wid: number, wtype: MediaType, list: Watched[]) {
|
||||
const wel = list.find((wl) => wl.content.tmdbId === wid && wl.content.type === wtype);
|
||||
if (!wel) return {};
|
||||
console.log(wid, wtype, wel?.content.title, wel?.status, wel?.rating);
|
||||
return {
|
||||
status: wel.status,
|
||||
rating: wel.rating
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a class to the parent node of a clicked element.
|
||||
* @param e Event with currentTarget.
|
||||
* @param c Class to add to parent.
|
||||
*/
|
||||
export function addClassToParent(
|
||||
e: Event & {
|
||||
currentTarget: EventTarget & Element;
|
||||
},
|
||||
c: string
|
||||
) {
|
||||
(e.currentTarget?.parentNode as HTMLDivElement)?.classList.add(c);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets "main" crew members from list of crew.
|
||||
* @param crew Crew
|
||||
* @returns Top Crew
|
||||
*/
|
||||
export function getTopCrew(crew: TMDBContentCreditsCrew[]) {
|
||||
return crew.filter(
|
||||
(c) => c.job === "Director" || c.job === "Writer" || c.job === "Characters" || c.job === "Story"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates what the transform-origin property should be
|
||||
* depending on where the scaled (poster) element will be
|
||||
* in the viewport to keep it in view.
|
||||
* @param e
|
||||
*/
|
||||
export function calculateTransformOrigin(
|
||||
e: Event & {
|
||||
currentTarget: EventTarget & HTMLLIElement;
|
||||
}
|
||||
) {
|
||||
const magicNumber = 26;
|
||||
const ctr = e.currentTarget.querySelector(".container") as HTMLElement;
|
||||
const pb = ctr.getBoundingClientRect();
|
||||
const sx = pb.x;
|
||||
const sw = pb.width;
|
||||
const wb = document.body.getBoundingClientRect();
|
||||
|
||||
if (ctr) {
|
||||
ctr.style.transformOrigin = "unset";
|
||||
const origins = [];
|
||||
// Overflow on right
|
||||
if (sx + sw + magicNumber > wb.x + wb.width) {
|
||||
origins.push("right");
|
||||
}
|
||||
// Overflow on left
|
||||
if (sx - magicNumber < wb.x) {
|
||||
origins.push("left");
|
||||
}
|
||||
// Overflow on bottom
|
||||
const ppb = e.currentTarget.getBoundingClientRect();
|
||||
if (ppb.bottom + magicNumber > window.innerHeight) {
|
||||
origins.push("bottom");
|
||||
}
|
||||
ctr.style.transformOrigin = `${origins.join(" ")}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ordinal suffix to use from day number (1`st`, 2`nd`, 3`rd`).
|
||||
* @param i Day number.
|
||||
*/
|
||||
export function getOrdinalSuffix(i: number) {
|
||||
const j = i % 10,
|
||||
k = i % 100;
|
||||
if (j == 1 && k != 11) {
|
||||
return "st";
|
||||
}
|
||||
if (j == 2 && k != 12) {
|
||||
return "nd";
|
||||
}
|
||||
if (j == 3 && k != 13) {
|
||||
return "rd";
|
||||
}
|
||||
return "th";
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { notifications } from "@/store";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export interface Notification {
|
||||
/**
|
||||
* Text shown in popup;
|
||||
*/
|
||||
text: string;
|
||||
|
||||
/**
|
||||
* Type of notification, controls the style.
|
||||
*/
|
||||
type?: "error" | "success";
|
||||
|
||||
/**
|
||||
* How long in milliseconds the popup will stay shown for.
|
||||
*/
|
||||
time?: number;
|
||||
}
|
||||
|
||||
export function notify(n: Notification) {
|
||||
const notifs = get(notifications);
|
||||
const id = Math.random();
|
||||
notifs.push({ id, ...n });
|
||||
notifications.update(() => notifs);
|
||||
setTimeout(() => unNotify(id), n.time ?? 2500);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function unNotify(id: number) {
|
||||
const ns = get(notifications);
|
||||
const dn = ns.filter((e) => e.id !== id);
|
||||
notifications.update(() => dn);
|
||||
}
|
||||
+271
-51
@@ -1,49 +1,163 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import { isTouch } from "@/lib/util/helpers";
|
||||
import { activeFilter, clearAllStores, watchedList } from "@/store";
|
||||
import axios from "axios";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
const username = localStorage.getItem("username");
|
||||
|
||||
let searchTimeout: number;
|
||||
let subMenuShown = false;
|
||||
let filterMenuShown = false;
|
||||
|
||||
$: filter = $activeFilter;
|
||||
|
||||
function handleProfileClick() {
|
||||
if (!localStorage.getItem("token")) {
|
||||
goto("/login");
|
||||
} else {
|
||||
subMenuShown = !subMenuShown;
|
||||
filterMenuShown = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch(ev: Event) {
|
||||
function handleFilterClick() {
|
||||
filterMenuShown = !filterMenuShown;
|
||||
subMenuShown = false;
|
||||
}
|
||||
|
||||
function handleSearch(ev: KeyboardEvent) {
|
||||
if (
|
||||
ev.key === "Tab" ||
|
||||
ev.key === "CapsLock" ||
|
||||
ev.key === "OS" ||
|
||||
ev.key === "ArrowLeft" ||
|
||||
ev.key === "ArrowRight" ||
|
||||
ev.key === "ArrowUp" ||
|
||||
ev.key === "ArrowDown"
|
||||
)
|
||||
return;
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
const target = ev.target as HTMLInputElement;
|
||||
const query = target?.value;
|
||||
if (query) {
|
||||
goto(`/search/${query}`).then(() => {
|
||||
target?.focus();
|
||||
});
|
||||
}
|
||||
}, 400);
|
||||
searchTimeout = setTimeout(
|
||||
() => {
|
||||
const target = ev.target as HTMLInputElement;
|
||||
const query = target?.value.trim();
|
||||
if (!query) return;
|
||||
if (query) {
|
||||
goto(`/search/${query}`).then(() => {
|
||||
target?.focus();
|
||||
});
|
||||
}
|
||||
},
|
||||
isTouch() ? 800 : 400
|
||||
);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem("token");
|
||||
goto("/login")
|
||||
clearAllStores();
|
||||
goto("/login");
|
||||
}
|
||||
|
||||
function profile() {
|
||||
goto("/profile");
|
||||
subMenuShown = false;
|
||||
}
|
||||
|
||||
async function getWatchedList() {
|
||||
if (localStorage.getItem("token")) {
|
||||
const w = await axios.get("/watched");
|
||||
if (w?.data?.length > 0) {
|
||||
watchedList.update((wl) => (wl = w.data));
|
||||
}
|
||||
} else {
|
||||
goto("/login?again=1");
|
||||
}
|
||||
}
|
||||
|
||||
function filterClicked(type: string, modeType: string = "UPDOWN") {
|
||||
const af = get(activeFilter);
|
||||
let mode: string;
|
||||
if (modeType === "UPDOWN") {
|
||||
mode = "UP";
|
||||
if (af[0] == type) {
|
||||
if (af[1] === "UP") {
|
||||
mode = "DOWN";
|
||||
} else if (af[1] === "DOWN") {
|
||||
mode = "";
|
||||
}
|
||||
}
|
||||
} else if (modeType === "TOGGLE") {
|
||||
mode = "ON";
|
||||
if (af[0] == type) {
|
||||
if (af[1] === "ON") {
|
||||
mode = "OFF";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error("filterClicked() ran without a valid modeType:", modeType);
|
||||
return;
|
||||
}
|
||||
activeFilter.update((af) => (af = [type, mode]));
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
<a href="/"><h1>Watcharr</h1></a>
|
||||
<a href="/">
|
||||
<span class="large">Watcharr</span>
|
||||
<span class="small">W</span>
|
||||
</a>
|
||||
<input type="text" placeholder="Search" on:keydown={handleSearch} />
|
||||
<button class="plain face" on:click={handleProfileClick}>:)</button>
|
||||
{#if subMenuShown}
|
||||
<div>
|
||||
<button class="plain" style="text-decoration: line-through;">Profile</button>
|
||||
<button class="plain" on:click={() => logout()}>Logout</button>
|
||||
<div class="btns">
|
||||
<!-- Only show on watched list -->
|
||||
{#if $page.url?.pathname === "/"}
|
||||
<button class="plain filter" on:click={handleFilterClick}>
|
||||
<Icon i="filter" />
|
||||
</button>
|
||||
{#if filterMenuShown}
|
||||
<div class="filter-menu">
|
||||
<button
|
||||
class={`plain ${filter[0] == "DATEADDED" ? filter[1].toLowerCase() : ""}`}
|
||||
on:click={() => filterClicked("DATEADDED")}
|
||||
>
|
||||
Date Added
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${filter[0] == "ALPHA" ? filter[1].toLowerCase() : ""}`}
|
||||
on:click={() => filterClicked("ALPHA")}
|
||||
>
|
||||
Alphabetical
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<button class="plain face" on:click={handleProfileClick}>:)</button>
|
||||
{#if subMenuShown}
|
||||
<div class="face-menu">
|
||||
{#if username}
|
||||
<h5 title={username}>Hi {username}!</h5>
|
||||
{/if}
|
||||
<button class="plain" on:click={() => profile()}>Profile</button>
|
||||
<button class="plain" on:click={() => logout()}>Logout</button>
|
||||
<!-- svelte-ignore missing-declaration -->
|
||||
<span>v{__WATCHARR_VERSION__}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<slot />
|
||||
{#await getWatchedList()}
|
||||
<Spinner />
|
||||
{:then}
|
||||
<slot />
|
||||
{:catch err}
|
||||
<PageError pretty="Failed to load watched list!" error={err} />
|
||||
{/await}
|
||||
|
||||
<style lang="scss">
|
||||
nav {
|
||||
@@ -52,15 +166,40 @@
|
||||
justify-content: space-between;
|
||||
margin: 10px 20px 28px 20px;
|
||||
position: relative;
|
||||
gap: 20px;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
-webkit-text-stroke: 1.5px black;
|
||||
color: black;
|
||||
font-family: "Shrikhand", system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-size: 35px;
|
||||
transition: -webkit-text-stroke 150ms ease, color 150ms ease, font-weight 150ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: white;
|
||||
-webkit-text-stroke: 3px black;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
span.large {
|
||||
display: block;
|
||||
width: 185.2px;
|
||||
}
|
||||
|
||||
span.small {
|
||||
display: none;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 580px) {
|
||||
span.large {
|
||||
display: none;
|
||||
}
|
||||
span.small {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
@@ -68,40 +207,121 @@
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
box-shadow: 4px 4px 0px 0px rgba(0, 0, 0, 1);
|
||||
transition: width 150ms ease, box-shadow 150ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
box-shadow: 2px 2px 0px 0px rgba(0, 0, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
button.face {
|
||||
font-family: "Rampart One", system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-size: 25px;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div {
|
||||
.btns {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 55px;
|
||||
padding: 10px;
|
||||
border: 3px solid black;
|
||||
border-radius: 10px;
|
||||
background-color: white;
|
||||
list-style: none;
|
||||
z-index: 50;
|
||||
flex-flow: row;
|
||||
gap: 30px;
|
||||
|
||||
button {
|
||||
font-size: 14px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 200ms ease;
|
||||
button.filter {
|
||||
padding-top: 2px;
|
||||
width: 28px;
|
||||
transition: fill 150ms ease, stroke 150ms ease, stroke-width 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 1);
|
||||
color: white;
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
fill: white;
|
||||
stroke: black;
|
||||
stroke-width: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
button.face {
|
||||
font-family: "Shrikhand", system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-size: 25px;
|
||||
transform: rotate(90deg);
|
||||
cursor: pointer;
|
||||
transition: -webkit-text-stroke 150ms ease, color 150ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: white;
|
||||
-webkit-text-stroke: 1.5px black;
|
||||
}
|
||||
}
|
||||
|
||||
div.filter-menu {
|
||||
width: 180px;
|
||||
|
||||
& > button {
|
||||
position: relative;
|
||||
|
||||
&.down::before {
|
||||
content: "\2193";
|
||||
}
|
||||
|
||||
&.up::before {
|
||||
content: "\2191";
|
||||
}
|
||||
|
||||
&.on::before {
|
||||
content: "\2713";
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 12px;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 55px;
|
||||
width: 125px;
|
||||
padding: 10px;
|
||||
border: 3px solid black;
|
||||
border-radius: 10px;
|
||||
background-color: white;
|
||||
list-style: none;
|
||||
z-index: 50;
|
||||
|
||||
h5 {
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 14px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 200ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 1);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: gray;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 580px) {
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,15 +4,13 @@ export const csr = true;
|
||||
|
||||
import { goto } from "$app/navigation";
|
||||
import axios from "axios";
|
||||
import { watchedList } from "@/store";
|
||||
import { error } from "@sveltejs/kit";
|
||||
import type { LayoutLoad } from "./$types";
|
||||
const { MODE } = import.meta.env;
|
||||
import { baseURL } from "@/lib/util/api";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
|
||||
axios.interceptors.request.use(
|
||||
(config) => {
|
||||
if (!config.baseURL) {
|
||||
config.baseURL = MODE === "development" ? "http://127.0.0.1:3080" : "/api";
|
||||
config.baseURL = baseURL;
|
||||
|
||||
// Only want to set auth header if requesting to our backend.
|
||||
const token = localStorage.getItem("token");
|
||||
@@ -39,25 +37,10 @@ axios.interceptors.response.use(
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
console.error("Recieved 401 response, going to login.");
|
||||
notify({ text: "Request Authorization Failed!", type: "error" });
|
||||
localStorage.removeItem("token");
|
||||
goto("/login?again=1");
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export const load = (async () => {
|
||||
try {
|
||||
console.log("load");
|
||||
if (localStorage.getItem("token")) {
|
||||
const w = await axios.get("/watched");
|
||||
if (w?.data?.length > 0) {
|
||||
watchedList.update((wl) => (wl = w.data));
|
||||
}
|
||||
} else {
|
||||
goto("/login?again=1");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error loading watched content:", err);
|
||||
error(500, "Error loading watched content!");
|
||||
}
|
||||
}) satisfies LayoutLoad;
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<script lang="ts">
|
||||
import Poster from "@/lib/Poster.svelte";
|
||||
import PosterList from "@/lib/PosterList.svelte";
|
||||
import { updateWatched } from "@/lib/api";
|
||||
import { watchedList } from "@/store";
|
||||
import { removeWatched, updateWatched } from "@/lib/util/api";
|
||||
import { activeFilter, watchedList } from "@/store";
|
||||
|
||||
$: watched = $watchedList;
|
||||
$: filter = $activeFilter;
|
||||
$: watched = $watchedList.sort((a, b) => {
|
||||
if (filter[0] === "DATEADDED" && filter[1] === "UP") {
|
||||
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
||||
} else if (filter[0] === "ALPHA") {
|
||||
if (filter[1] === "UP") return a.content.title.localeCompare(b.content.title);
|
||||
else if (filter[1] === "DOWN") return b.content.title.localeCompare(a.content.title);
|
||||
}
|
||||
// default DATEADDED DOWN
|
||||
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -15,31 +25,18 @@
|
||||
{#if watched?.length > 0}
|
||||
{#each watched as w (w.id)}
|
||||
<Poster
|
||||
poster={"http://localhost:3080/img" + w.content.poster_path}
|
||||
title={w.content.title}
|
||||
desc={w.content.overview}
|
||||
media={{
|
||||
id: w.content.tmdbId,
|
||||
poster_path: w.content.poster_path,
|
||||
title: w.content.title,
|
||||
overview: w.content.overview,
|
||||
media_type: w.content.type
|
||||
}}
|
||||
rating={w.rating}
|
||||
status={w.status}
|
||||
onBtnClicked={(type) => {
|
||||
updateWatched(w.id, type)
|
||||
?.then(() => {
|
||||
w.status = type;
|
||||
$watchedList = watched;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}}
|
||||
onRatingChanged={(rating) => {
|
||||
updateWatched(w.id, undefined, rating)
|
||||
?.then(() => {
|
||||
w.rating = rating;
|
||||
$watchedList = watched;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}}
|
||||
onStatusChanged={(t) => updateWatched(w.content.tmdbId, w.content.type, t)}
|
||||
onRatingChanged={(r) => updateWatched(w.content.tmdbId, w.content.type, undefined, r)}
|
||||
onDeleteClicked={() => removeWatched(w.id)}
|
||||
/>
|
||||
{/each}
|
||||
{:else}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
<script lang="ts">
|
||||
import Error from "@/lib/Error.svelte";
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import PersonPoster from "@/lib/PersonPoster.svelte";
|
||||
import Rating from "@/lib/Rating.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import Status from "@/lib/Status.svelte";
|
||||
import HorizontalList from "@/lib/HorizontalList.svelte";
|
||||
import { updateWatched } from "@/lib/util/api";
|
||||
import { watchedList } from "@/store";
|
||||
import type {
|
||||
TMDBContentCredits,
|
||||
TMDBContentCreditsCrew,
|
||||
TMDBMovieDetails,
|
||||
WatchedStatus
|
||||
} from "@/types";
|
||||
import axios from "axios";
|
||||
import { getTopCrew } from "@/lib/util/helpers.js";
|
||||
import Activity from "@/lib/Activity.svelte";
|
||||
|
||||
export let data;
|
||||
|
||||
$: wListItem = $watchedList.find((w) => w.content.tmdbId === data.movieId);
|
||||
|
||||
async function getMovie() {
|
||||
return (await axios.get(`/content/movie/${data.movieId}`)).data as TMDBMovieDetails;
|
||||
}
|
||||
|
||||
async function getMovieCredits() {
|
||||
const credits = (await axios.get(`/content/movie/${data.movieId}/credits`))
|
||||
.data as TMDBContentCredits & { topCrew: TMDBContentCreditsCrew[] };
|
||||
if (credits.crew?.length > 0) {
|
||||
credits.topCrew = getTopCrew(credits.crew);
|
||||
}
|
||||
return credits;
|
||||
}
|
||||
|
||||
function contentChanged(newStatus?: WatchedStatus, newRating?: number) {
|
||||
updateWatched(data.movieId, "movie", newStatus, newRating);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await getMovie()}
|
||||
<Spinner />
|
||||
{:then movie}
|
||||
{#if Object.keys(movie).length > 0}
|
||||
<div>
|
||||
<div class="content">
|
||||
<img
|
||||
class="backdrop"
|
||||
src={"https://www.themoviedb.org/t/p/w1920_and_h800_multi_faces" + movie.backdrop_path}
|
||||
alt=""
|
||||
/>
|
||||
<div class="vignette" />
|
||||
|
||||
<div class="details-container">
|
||||
<img class="poster" src={"https://image.tmdb.org/t/p/w500" + movie.poster_path} alt="" />
|
||||
|
||||
<div class="details">
|
||||
<span class="title-container">
|
||||
<a href={movie.homepage} target="_blank">{movie.title}</a>
|
||||
<span>{new Date(Date.parse(movie.release_date)).getFullYear()}</span>
|
||||
</span>
|
||||
|
||||
<span class="quick-info">
|
||||
<span>{movie.runtime}m</span>
|
||||
|
||||
<div>
|
||||
{#each movie.genres as g, i}
|
||||
<span>{g.name}{i !== movie.genres.length - 1 ? ", " : ""}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<!-- <span>{movie.tagline}</span> -->
|
||||
|
||||
<!-- {movie.status} -->
|
||||
|
||||
<span style="font-weight: bold; font-size: 14px;">Overview</span>
|
||||
<p>{movie.overview}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<div class="review">
|
||||
<!-- <span>What did you think?</span> -->
|
||||
<Rating rating={wListItem?.rating} onChange={(n) => contentChanged(undefined, n)} />
|
||||
<Status status={wListItem?.status} onChange={(n) => contentChanged(n)} />
|
||||
</div>
|
||||
|
||||
{#await getMovieCredits()}
|
||||
<Spinner />
|
||||
{:then credits}
|
||||
{#if credits.topCrew?.length > 0}
|
||||
<div class="creators">
|
||||
{#each credits.topCrew as crew}
|
||||
<div>
|
||||
<span>{crew.name}</span>
|
||||
<span>{crew.job}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="cast">
|
||||
<HorizontalList title="Cast">
|
||||
{#each credits.cast?.slice(0, 50) as cast}
|
||||
<PersonPoster
|
||||
id={cast.id}
|
||||
name={cast.name}
|
||||
path={cast.profile_path}
|
||||
role={cast.character}
|
||||
zoomOnHover={false}
|
||||
/>
|
||||
{/each}
|
||||
</HorizontalList>
|
||||
</div>
|
||||
{:catch err}
|
||||
<Error error={err} pretty="Failed to load cast!" />
|
||||
{/await}
|
||||
|
||||
{#if wListItem}
|
||||
<Activity activity={wListItem?.activity} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
Movie not found
|
||||
{/if}
|
||||
{:catch err}
|
||||
<PageError pretty="Failed to load movie!" error={err} />
|
||||
{/await}
|
||||
|
||||
<style lang="scss">
|
||||
.content {
|
||||
position: relative;
|
||||
color: white;
|
||||
margin-bottom: 15px;
|
||||
|
||||
img.backdrop {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: -2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(4px) grayscale(80%);
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.vignette {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba($color: #000000, $alpha: 0.7);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.details-container {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 35px;
|
||||
max-width: 1100px;
|
||||
padding: 40px 80px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
img.poster {
|
||||
width: 235px;
|
||||
height: 100%;
|
||||
box-shadow: 0px 0px 14px -4px #9c8080;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 5px;
|
||||
|
||||
.title-container {
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 20px;
|
||||
color: rgba($color: #fff, $alpha: 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-info {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 570px) {
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
gap: 30px;
|
||||
padding: 20px 50px;
|
||||
max-width: 1200px;
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.review {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.cast {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.creators {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 35px;
|
||||
margin: 10px 60px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
min-width: 150px;
|
||||
|
||||
span:first-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import type { PageLoad } from "../../search/[query]/$types";
|
||||
|
||||
export const load = (async ({ params }) => {
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
error(400);
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
movieId: Number(id)
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
@@ -0,0 +1,226 @@
|
||||
<script lang="ts">
|
||||
import Error from "@/lib/Error.svelte";
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import Poster from "@/lib/Poster.svelte";
|
||||
import PosterList from "@/lib/PosterList.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import { removeWatched, updateWatched } from "@/lib/util/api";
|
||||
import { getWatchedDependedProps } from "@/lib/util/helpers";
|
||||
import { watchedList } from "@/store";
|
||||
import type { TMDBPersonCombinedCredits, TMDBPersonDetails } from "@/types";
|
||||
import axios from "axios";
|
||||
|
||||
export let data;
|
||||
|
||||
$: wList = $watchedList;
|
||||
|
||||
async function getPerson() {
|
||||
return (await axios.get(`/content/person/${data.personId}`)).data as TMDBPersonDetails;
|
||||
}
|
||||
|
||||
async function getPersonCredits() {
|
||||
const credits = (await axios.get(`/content/person/${data.personId}/credits`))
|
||||
.data as TMDBPersonCombinedCredits;
|
||||
credits.cast = credits.cast?.sort((a, b) => b.vote_count - a.vote_count);
|
||||
return credits;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#await getPerson()}
|
||||
<Spinner />
|
||||
{:then person}
|
||||
{#if Object.keys(person).length > 0}
|
||||
<div class="content">
|
||||
<img
|
||||
class="backdrop"
|
||||
src={"https://www.themoviedb.org/t/p/w1920_and_h800_multi_faces" + person.profile_path}
|
||||
alt=""
|
||||
/>
|
||||
<div class="vignette" />
|
||||
|
||||
<div class="details-container">
|
||||
<img
|
||||
class="poster"
|
||||
src={"https://image.tmdb.org/t/p/w500" + person.profile_path}
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<div class="details">
|
||||
<span class="title-container">
|
||||
<a href={person.homepage} target="_blank">{person.name}</a>
|
||||
<span />
|
||||
</span>
|
||||
|
||||
{#if person.biography}
|
||||
<span style="font-weight: bold; font-size: 14px;">Biography</span>
|
||||
<!-- Show just the first paragraph -->
|
||||
<p>{person.biography?.split("\n")[0]}</p>
|
||||
{/if}
|
||||
|
||||
<div class="detail-info">
|
||||
{#if person.known_for_department}
|
||||
<div>
|
||||
<span>Department</span>
|
||||
<span>{person.known_for_department}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if person.place_of_birth}
|
||||
<div>
|
||||
<span>Born In</span>
|
||||
<span>{person.place_of_birth}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if person.birthday}
|
||||
<div>
|
||||
<span>Born On</span>
|
||||
<span>{new Date(Date.parse(person.birthday)).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if person.deathday}
|
||||
<div>
|
||||
<span>Died On</span>
|
||||
<span>{new Date(Date.parse(person.deathday)).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
person not found
|
||||
{/if}
|
||||
{:catch err}
|
||||
<PageError pretty="Failed to load tv person!" error={err} />
|
||||
{/await}
|
||||
|
||||
{#await getPersonCredits()}
|
||||
<Spinner />
|
||||
{:then credits}
|
||||
<div class="page">
|
||||
<PosterList>
|
||||
{#each credits?.cast as c}
|
||||
<Poster
|
||||
media={c}
|
||||
onStatusChanged={(t) => updateWatched(c.id, c.media_type, t)}
|
||||
onRatingChanged={(r) => updateWatched(c.id, c.media_type, undefined, r)}
|
||||
onDeleteClicked={() => removeWatched(c.id)}
|
||||
{...getWatchedDependedProps(c.id, c.media_type, wList)}
|
||||
/>
|
||||
{/each}
|
||||
</PosterList>
|
||||
</div>
|
||||
{:catch err}
|
||||
<Error pretty="Failed to load credits!" error={err} />
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.content {
|
||||
position: relative;
|
||||
color: white;
|
||||
margin-bottom: 15px;
|
||||
|
||||
img.backdrop {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: -2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(4px) grayscale(80%);
|
||||
/* mix-blend-mode: multiply; */
|
||||
}
|
||||
|
||||
.vignette {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba($color: #000000, $alpha: 0.7);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.details-container {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 35px;
|
||||
max-width: 1100px;
|
||||
padding: 40px 80px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
img.poster {
|
||||
width: 235px;
|
||||
height: 100%;
|
||||
box-shadow: 0px 0px 14px -4px #9c8080;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 5px;
|
||||
|
||||
.title-container {
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 20px;
|
||||
color: rgba($color: #fff, $alpha: 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
max-height: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.detail-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 35px;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
|
||||
span:first-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 570px) {
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
padding: 10px 0px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import type { PageLoad } from "../../search/[query]/$types";
|
||||
|
||||
export const load = (async ({ params }) => {
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
error(400);
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
personId: Number(id)
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import Error from "@/lib/Error.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import { getOrdinalSuffix, monthsShort } from "@/lib/util/helpers";
|
||||
import type { Profile } from "@/types";
|
||||
import axios from "axios";
|
||||
|
||||
async function getProfile() {
|
||||
return (await axios.get(`/profile`)).data as Profile;
|
||||
}
|
||||
|
||||
function formatDate(d: Date) {
|
||||
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${
|
||||
monthsShort[d.getMonth()]
|
||||
} ${d.getFullYear()}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="content">
|
||||
<div class="inner">
|
||||
<h2>Hey {localStorage.getItem("username")}</h2>
|
||||
|
||||
<div class="stats">
|
||||
{#await getProfile()}
|
||||
<Spinner />
|
||||
{:then profile}
|
||||
<div>
|
||||
<span>{formatDate(new Date(profile.joined))}</span>
|
||||
<span>Joined</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="large">{profile.moviesWatched}</span>
|
||||
<span>Movies Watched</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="large">{profile.showsWatched}</span>
|
||||
<span>Shows Watched</span>
|
||||
</div>
|
||||
{:catch err}
|
||||
<Error error={err} pretty="Failed to get stats!" />
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
|
||||
.inner {
|
||||
min-width: 400px;
|
||||
max-width: 400px;
|
||||
margin: 0 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 12px;
|
||||
margin-top: 15px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
padding: 20px 15px;
|
||||
background-color: rgba(128, 128, 128, 0.226);
|
||||
border-radius: 8px;
|
||||
|
||||
> span:first-child {
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
|
||||
&.large {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
> span:last-child {
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,63 +2,20 @@
|
||||
import Poster from "@/lib/Poster.svelte";
|
||||
import PosterList from "@/lib/PosterList.svelte";
|
||||
import { watchedList } from "@/store";
|
||||
import type { ContentType, Rating, Watched, WatchedAddRequest, WatchedStatus } from "@/types";
|
||||
import type { ContentSearch } from "./+page";
|
||||
import { removeWatched, updateWatched } from "@/lib/util/api";
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import axios from "axios";
|
||||
import { updateWatched } from "@/lib/api";
|
||||
import { getWatchedDependedProps } from "@/lib/util/helpers";
|
||||
import PersonPoster from "@/lib/PersonPoster.svelte";
|
||||
|
||||
export let data: ContentSearch;
|
||||
export let data;
|
||||
|
||||
$: wList = $watchedList;
|
||||
|
||||
function addWatched(
|
||||
contentId: number,
|
||||
contentType: ContentType,
|
||||
status?: WatchedStatus,
|
||||
rating?: Rating
|
||||
) {
|
||||
// If item is already in watched store, run update request instead
|
||||
const wEntry = wList.find((w) => w.content.id === contentId);
|
||||
if (wEntry?.id) {
|
||||
updateWatched(wEntry.id, status, rating)
|
||||
?.then(() => {
|
||||
if (status) wEntry.status = status;
|
||||
wEntry.rating = rating;
|
||||
$watchedList = wList;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Add new watched item
|
||||
axios
|
||||
.post("/watched", {
|
||||
contentId,
|
||||
contentType,
|
||||
rating,
|
||||
status
|
||||
} as WatchedAddRequest)
|
||||
.then((resp) => {
|
||||
console.log("Added watched:", resp.data);
|
||||
$watchedList.push(resp.data as Watched);
|
||||
$watchedList = $watchedList;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
// Not passing wList from #each loop caused it not to have reactivity.
|
||||
// Passing it through must allow it to recognize it as a dependency?
|
||||
function getWatchedDependedProps(wid: number, list: Watched[]) {
|
||||
const wel = list.find((wl) => wl.content.id === wid);
|
||||
if (!wel) return {};
|
||||
console.log(wel.content.title, wel.status, wel.rating);
|
||||
return {
|
||||
status: wel.status,
|
||||
rating: wel.rating
|
||||
};
|
||||
async function search(query: string) {
|
||||
return (await axios.get(`/content/${query}`)).data as ContentSearch;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -66,19 +23,39 @@
|
||||
<title>Content Search</title>
|
||||
</svelte:head>
|
||||
|
||||
<PosterList>
|
||||
{#if data?.results?.length > 0}
|
||||
{#each data.results as w (w.id)}
|
||||
<Poster
|
||||
poster={"https://image.tmdb.org/t/p/w500" + w.poster_path}
|
||||
title={w.title ?? w.name}
|
||||
desc={w.overview}
|
||||
onBtnClicked={(t, r) => addWatched(w.id, w.title ? "movie" : "tv", t, r)}
|
||||
onRatingChanged={(r) => addWatched(w.id, w.title ? "movie" : "tv", undefined, r)}
|
||||
{...getWatchedDependedProps(w.id, wList)}
|
||||
/>
|
||||
{/each}
|
||||
{:else}
|
||||
No Search Results!
|
||||
{/if}
|
||||
</PosterList>
|
||||
{#if data.slug}
|
||||
{#await search(data.slug)}
|
||||
<Spinner />
|
||||
{:then results}
|
||||
<PosterList>
|
||||
{#if results?.results?.length > 0}
|
||||
{#each results.results as w (w.id)}
|
||||
{#if w.media_type === "person"}
|
||||
<PersonPoster id={w.id} name={w.name} path={w.profile_path} />
|
||||
{:else}
|
||||
<Poster
|
||||
media={w}
|
||||
onStatusChanged={(t) => updateWatched(w.id, w.media_type, t)}
|
||||
onRatingChanged={(r) => updateWatched(w.id, w.media_type, undefined, r)}
|
||||
onDeleteClicked={() => {
|
||||
const wl = wList.find((wi) => wi.content.tmdbId === w.id);
|
||||
if (!wl) {
|
||||
console.error("Failed to find item in watched list, cant remove!");
|
||||
return;
|
||||
}
|
||||
removeWatched(wl.id);
|
||||
}}
|
||||
{...getWatchedDependedProps(w.id, w.media_type, wList)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
No Search Results!
|
||||
{/if}
|
||||
</PosterList>
|
||||
{:catch err}
|
||||
<PageError pretty="Failed to load watched list!" error={err} />
|
||||
{/await}
|
||||
{:else}
|
||||
<h2>No Search Query!</h2>
|
||||
{/if}
|
||||
|
||||
@@ -1,34 +1,55 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import type { Content } from "@/types";
|
||||
import axios from "axios";
|
||||
|
||||
export interface ContentSearch {
|
||||
page: number;
|
||||
results: ContentSearchResult[];
|
||||
results: (ContentSearchMovie | ContentSearchTv | ContentSearchPerson)[];
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
}
|
||||
|
||||
export interface ContentSearchResult {
|
||||
adult: boolean;
|
||||
backdrop_path: string;
|
||||
id: number;
|
||||
original_language: string;
|
||||
overview: string;
|
||||
poster_path: string;
|
||||
media_type: string;
|
||||
export interface ContentSearchMovie {
|
||||
poster_path?: string;
|
||||
adult?: boolean;
|
||||
overview?: string;
|
||||
release_date?: string;
|
||||
original_title?: string;
|
||||
genre_ids?: number[];
|
||||
popularity: number;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
name?: string;
|
||||
original_name?: string;
|
||||
id?: number;
|
||||
media_type: "movie";
|
||||
original_language?: string;
|
||||
title?: string;
|
||||
backdrop_path?: string;
|
||||
popularity?: number;
|
||||
vote_count?: number;
|
||||
video?: boolean;
|
||||
vote_average?: number;
|
||||
}
|
||||
|
||||
export interface ContentSearchTv {
|
||||
poster_path?: string;
|
||||
popularity?: number;
|
||||
id?: number;
|
||||
overview?: string;
|
||||
backdrop_path?: string;
|
||||
vote_average?: number;
|
||||
media_type: "tv";
|
||||
first_air_date?: string;
|
||||
origin_country?: string[];
|
||||
title?: string;
|
||||
original_title?: string;
|
||||
release_date?: string;
|
||||
video?: boolean;
|
||||
genre_ids?: number[];
|
||||
original_language?: string;
|
||||
vote_count?: number;
|
||||
name?: string;
|
||||
original_name?: string;
|
||||
}
|
||||
|
||||
export interface ContentSearchPerson {
|
||||
profile_path?: string;
|
||||
adult?: boolean;
|
||||
id?: number;
|
||||
media_type: "person";
|
||||
known_for?: ContentSearchMovie | ContentSearchTv;
|
||||
name?: string;
|
||||
popularity?: number;
|
||||
}
|
||||
|
||||
export async function load({ params }) {
|
||||
@@ -39,13 +60,7 @@ export async function load({ params }) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
return (await axios.get(`/content/${query}`)).data as ContentSearch;
|
||||
} catch (err: any) {
|
||||
if (err.response) {
|
||||
error(500, err.response.data.error);
|
||||
} else {
|
||||
error(500, err.message);
|
||||
}
|
||||
}
|
||||
return {
|
||||
slug: query
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<script lang="ts">
|
||||
import Activity from "@/lib/Activity.svelte";
|
||||
import Error from "@/lib/Error.svelte";
|
||||
import HorizontalList from "@/lib/HorizontalList.svelte";
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import PersonPoster from "@/lib/PersonPoster.svelte";
|
||||
import Rating from "@/lib/Rating.svelte";
|
||||
import SeasonsList from "@/lib/SeasonsList.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import Status from "@/lib/Status.svelte";
|
||||
import { updateWatched } from "@/lib/util/api";
|
||||
import { getTopCrew } from "@/lib/util/helpers.js";
|
||||
import { watchedList } from "@/store";
|
||||
import type {
|
||||
TMDBContentCredits,
|
||||
TMDBContentCreditsCrew,
|
||||
TMDBShowDetails,
|
||||
WatchedStatus
|
||||
} from "@/types";
|
||||
import axios from "axios";
|
||||
|
||||
export let data;
|
||||
|
||||
$: wListItem = $watchedList.find((w) => w.content.tmdbId === data.tvId);
|
||||
|
||||
async function getShow() {
|
||||
return (await axios.get(`/content/tv/${data.tvId}`)).data as TMDBShowDetails;
|
||||
}
|
||||
|
||||
async function getTvCredits() {
|
||||
const credits = (await axios.get(`/content/tv/${data.tvId}/credits`))
|
||||
.data as TMDBContentCredits & { topCrew: TMDBContentCreditsCrew[] };
|
||||
if (credits.crew?.length > 0) {
|
||||
credits.topCrew = getTopCrew(credits.crew);
|
||||
}
|
||||
return credits;
|
||||
}
|
||||
|
||||
function contentChanged(newStatus?: WatchedStatus, newRating?: number) {
|
||||
updateWatched(data.tvId, "tv", newStatus, newRating);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await getShow()}
|
||||
<Spinner />
|
||||
{:then show}
|
||||
{#if Object.keys(show).length > 0}
|
||||
<div>
|
||||
<div class="content">
|
||||
<img
|
||||
class="backdrop"
|
||||
src={"https://www.themoviedb.org/t/p/w1920_and_h800_multi_faces" + show.backdrop_path}
|
||||
alt=""
|
||||
/>
|
||||
<div class="vignette" />
|
||||
|
||||
<div class="details-container">
|
||||
<img class="poster" src={"https://image.tmdb.org/t/p/w500" + show.poster_path} alt="" />
|
||||
|
||||
<div class="details">
|
||||
<span class="title-container">
|
||||
<a href={show.homepage} target="_blank">{show.name}</a>
|
||||
<span>{new Date(Date.parse(show.first_air_date)).getFullYear()}</span>
|
||||
</span>
|
||||
|
||||
<span class="quick-info">
|
||||
<span>{show.episode_run_time}m</span>
|
||||
|
||||
<div>
|
||||
{#each show.genres as g, i}
|
||||
<span>{g.name}{i !== show.genres.length - 1 ? ", " : ""}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<span style="font-weight: bold; font-size: 14px;">Overview</span>
|
||||
<p>{show.overview}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<div class="review">
|
||||
<!-- <span>What did you think?</span> -->
|
||||
<Rating rating={wListItem?.rating} onChange={(n) => contentChanged(undefined, n)} />
|
||||
<Status status={wListItem?.status} onChange={(n) => contentChanged(n)} />
|
||||
</div>
|
||||
|
||||
{#await getTvCredits()}
|
||||
<Spinner />
|
||||
{:then credits}
|
||||
{#if credits.topCrew?.length > 0}
|
||||
<div class="creators">
|
||||
{#each credits.topCrew as crew}
|
||||
<div>
|
||||
<span>{crew.name}</span>
|
||||
<span>{crew.job}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="cast">
|
||||
<HorizontalList title="Cast">
|
||||
{#each credits.cast?.slice(0, 50) as cast}
|
||||
<PersonPoster
|
||||
id={cast.id}
|
||||
name={cast.name}
|
||||
path={cast.profile_path}
|
||||
role={cast.character}
|
||||
zoomOnHover={false}
|
||||
/>
|
||||
{/each}
|
||||
</HorizontalList>
|
||||
</div>
|
||||
{:catch err}
|
||||
<Error error={err} pretty="Failed to load cast!" />
|
||||
{/await}
|
||||
|
||||
{#if wListItem}
|
||||
<Activity activity={wListItem?.activity} />
|
||||
{/if}
|
||||
<SeasonsList tvId={data.tvId} seasons={show.seasons} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
Show not found
|
||||
{/if}
|
||||
{:catch err}
|
||||
<PageError pretty="Failed to load tv show!" error={err} />
|
||||
{/await}
|
||||
|
||||
<style lang="scss">
|
||||
.content {
|
||||
position: relative;
|
||||
color: white;
|
||||
margin-bottom: 15px;
|
||||
|
||||
img.backdrop {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: -2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(4px) grayscale(80%);
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.vignette {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba($color: #000000, $alpha: 0.7);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.details-container {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 35px;
|
||||
max-width: 1100px;
|
||||
padding: 40px 80px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
img.poster {
|
||||
width: 235px;
|
||||
height: 100%;
|
||||
box-shadow: 0px 0px 14px -4px #9c8080;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 5px;
|
||||
|
||||
.title-container {
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 20px;
|
||||
color: rgba($color: #fff, $alpha: 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-info {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 570px) {
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
gap: 30px;
|
||||
padding: 20px 50px;
|
||||
max-width: 1200px;
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.review {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.cast {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.creators {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 35px;
|
||||
margin: 10px 60px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
min-width: 150px;
|
||||
|
||||
span:first-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import type { PageLoad } from "../../search/[query]/$types";
|
||||
|
||||
export const load = (async ({ params }) => {
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
error(400);
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
tvId: Number(id)
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
@@ -1,15 +0,0 @@
|
||||
import axios from "axios";
|
||||
|
||||
const { MODE } = import.meta.env;
|
||||
|
||||
axios.interceptors.request.use(
|
||||
(config) => {
|
||||
if (!config.baseURL) {
|
||||
config.baseURL = MODE === "development" ? "http://127.0.0.1:3080" : "/api";
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
@@ -1,23 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import axios from "axios";
|
||||
import { page } from "$app/stores";
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import type { Icon as Icons } from "@/types";
|
||||
import { noAuthAxios } from "@/lib/util/api";
|
||||
import { onMount, afterUpdate } from "svelte";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
|
||||
let error: string;
|
||||
let login = true;
|
||||
|
||||
onMount(() => {
|
||||
if (localStorage.getItem("token")) {
|
||||
goto("/");
|
||||
}
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
if (!error && $page.url.searchParams.get("again")) {
|
||||
error = "Please Login Again";
|
||||
}
|
||||
});
|
||||
|
||||
function handleLogin(ev: SubmitEvent) {
|
||||
const fd = new FormData(ev.target! as HTMLFormElement);
|
||||
const user = fd.get("username");
|
||||
const pass = fd.get("password");
|
||||
|
||||
axios
|
||||
.post(`/auth${login ? "/" : "/register"}`, {
|
||||
username: fd.get("username"),
|
||||
password: fd.get("password")
|
||||
if (!user || !pass) {
|
||||
error = "Username and Password fields are required";
|
||||
return;
|
||||
}
|
||||
|
||||
let customAuthEP = "";
|
||||
if ((ev.submitter as HTMLButtonElement)?.name === "jellyfin") {
|
||||
customAuthEP = "jellyfin";
|
||||
}
|
||||
|
||||
noAuthAxios
|
||||
.post(`/auth${login ? `/${customAuthEP}` : "/register"}`, {
|
||||
username: user,
|
||||
password: pass
|
||||
})
|
||||
.then((resp) => {
|
||||
if (resp.data?.token) {
|
||||
console.log("Received token... logging in.");
|
||||
localStorage.setItem("token", resp.data.token);
|
||||
localStorage.setItem("username", String(user));
|
||||
goto("/");
|
||||
notify({ text: `Welcome ${user}!`, type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -28,6 +59,10 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function getLoginProviders() {
|
||||
return (await noAuthAxios.get("/auth/available")).data as Icons[];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -51,13 +86,21 @@
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" placeholder="Password" />
|
||||
|
||||
<button type="submit">
|
||||
{#if login}
|
||||
Login
|
||||
{:else}
|
||||
Sign Up
|
||||
{/if}
|
||||
</button>
|
||||
{#if login}
|
||||
<span class="login-with" style="font-weight: bold">Login With</span>
|
||||
<div class="login-btns">
|
||||
<button type="submit"><span class="watcharr">W</span>Watcharr</button>
|
||||
{#await getLoginProviders() then providers}
|
||||
{#each providers as p}
|
||||
<button type="submit" name="jellyfin" class="other"><Icon i={p} wh={18} />{p}</button>
|
||||
{/each}
|
||||
{/await}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="login-btns">
|
||||
<button type="submit">Sign Up</button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<button
|
||||
@@ -82,6 +125,7 @@
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 35px;
|
||||
}
|
||||
|
||||
.inner,
|
||||
@@ -90,11 +134,52 @@
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.inner h2 {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
label {
|
||||
align-self: flex-start;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
span.login-with {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-btns {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
|
||||
.watcharr {
|
||||
font-family: "Rampart One";
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
&.other {
|
||||
overflow: hidden;
|
||||
animation: 250ms ease otherbtn;
|
||||
|
||||
@keyframes otherbtn {
|
||||
from {
|
||||
width: 0px;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
+146
-2
@@ -1,7 +1,53 @@
|
||||
<script>
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import { unNotify } from "@/lib/util/notify";
|
||||
import { notifications } from "@/store";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
$: notifs = $notifications;
|
||||
|
||||
console.log(
|
||||
`%cWATCHARR v${__WATCHARR_VERSION__}`,
|
||||
"background: white;color: black;font-size: large;padding: 3px 5px;"
|
||||
);
|
||||
</script>
|
||||
|
||||
<div id="tooltip" />
|
||||
<div id="notifications">
|
||||
{#each notifs as n}
|
||||
<div class={n.type}>
|
||||
<span>{n.text}</span>
|
||||
<button
|
||||
class="plain"
|
||||
on:click={() => {
|
||||
unNotify(n.id);
|
||||
}}
|
||||
>
|
||||
<Icon i="close" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<slot />
|
||||
|
||||
<style lang="scss">
|
||||
@import url("https://fonts.googleapis.com/css2?family=Rampart+One&display=swap");
|
||||
@font-face {
|
||||
font-family: "Shrikhand";
|
||||
src: local("Shrikhand Regular"), local("Shrikhand-Regular"),
|
||||
url("/Shrikhand-Regular.woff2") format("woff2");
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Rampart One";
|
||||
src: local("Rampart One Regular"), local("RampartOne-Regular"),
|
||||
url("/RampartOne-Regular.woff2") format("woff2");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:global(*) {
|
||||
padding: 0;
|
||||
@@ -11,10 +57,16 @@
|
||||
|
||||
:global(body) {
|
||||
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
:global(h1, h2, h3, h4, h5) {
|
||||
font-family: "Rampart One", system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-family: "Shrikhand", system-ui, -apple-system, BlinkMacSystemFont;
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
:global(input) {
|
||||
@@ -41,10 +93,16 @@
|
||||
width: 100%;
|
||||
transition: background-color 100ms ease, opacity 100ms ease;
|
||||
|
||||
& :global(svg) {
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: black;
|
||||
color: white;
|
||||
fill: white;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -69,4 +127,90 @@
|
||||
:global(button.not-active) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
:global(#tooltip) {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
padding: 8px 5px;
|
||||
background-color: black;
|
||||
color: white;
|
||||
border: 1px solid white;
|
||||
border-radius: 6px;
|
||||
z-index: 99999;
|
||||
transition: top 100ms ease, left 100ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(#notifications) {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 8px;
|
||||
z-index: 99999;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
min-width: 200px;
|
||||
color: black;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba($color: #000000, $alpha: 0.2);
|
||||
box-shadow: 0 4px 10px rgba($color: #000000, $alpha: 0.2);
|
||||
animation: comein 250ms ease forwards;
|
||||
position: relative;
|
||||
|
||||
@keyframes comein {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: white;
|
||||
background-color: #f3555a;
|
||||
border: 1px solid #f3555a;
|
||||
|
||||
span {
|
||||
border-color: rgba($color: white, $alpha: 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: white;
|
||||
background-color: #28a745;
|
||||
border: 1px solid #28a745;
|
||||
|
||||
span {
|
||||
border-color: rgba($color: white, $alpha: 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-right: 12px;
|
||||
border-right: 1px solid rgba($color: black, $alpha: 0.2);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 8px;
|
||||
width: 22px;
|
||||
height: 100%;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,27 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { Watched } from "./types";
|
||||
import type { Notification } from "./lib/util/notify";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
export const watchedList = writable<Watched[]>([]);
|
||||
export const notifications = writable<(Notification & { id: number })[]>([]);
|
||||
export const activeFilter = writable<string[]>(["DATEADDED", "DOWN"]);
|
||||
|
||||
export const clearAllStores = () => {
|
||||
watchedList.set([]);
|
||||
notifications.set([]);
|
||||
activeFilter.set([]);
|
||||
};
|
||||
|
||||
// Rehydrate
|
||||
if (browser) {
|
||||
const raf = localStorage.getItem("activeFilter");
|
||||
if (raf) {
|
||||
activeFilter.update((v) => (v = JSON.parse(raf)));
|
||||
}
|
||||
}
|
||||
|
||||
// Save changes
|
||||
activeFilter.subscribe((v) => {
|
||||
if (browser) localStorage.setItem("activeFilter", JSON.stringify(v));
|
||||
});
|
||||
|
||||
+272
-26
@@ -1,45 +1,291 @@
|
||||
export type Rating = 1 | 2 | 3 | 4 | 5 | undefined;
|
||||
export type WatchedStatus = "PLANNED" | "WATCHING" | "FINISHED";
|
||||
export type WatchedStatus = "PLANNED" | "WATCHING" | "FINISHED" | "HOLD" | "DROPPED";
|
||||
export type ContentType = "tv" | "movie";
|
||||
export type MediaType = ContentType | "person";
|
||||
|
||||
export interface Content {
|
||||
adult: boolean;
|
||||
backdrop_path: string;
|
||||
id: number;
|
||||
title?: string;
|
||||
original_language: string;
|
||||
original_title?: string;
|
||||
overview: string;
|
||||
poster_path: string;
|
||||
media_type: string;
|
||||
genre_ids: number[];
|
||||
popularity: number;
|
||||
release_date?: string;
|
||||
video?: boolean;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
name?: string;
|
||||
original_name?: string;
|
||||
first_air_date?: string;
|
||||
origin_country?: string[];
|
||||
// Wasn't able to figure out how to import this type from its component file in other places, so its here for now.
|
||||
export type Icon =
|
||||
| "check"
|
||||
| "clock"
|
||||
| "calendar"
|
||||
| "thumb-down"
|
||||
| "play"
|
||||
| "pause"
|
||||
| "jellyfin"
|
||||
| "trash"
|
||||
| "close"
|
||||
| "filter";
|
||||
|
||||
interface dbModel {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string;
|
||||
}
|
||||
|
||||
export interface Watched {
|
||||
export interface Content {
|
||||
// id: number; // Not used
|
||||
tmdbId: number;
|
||||
title: string;
|
||||
poster_path: string;
|
||||
overview: string;
|
||||
type: ContentType;
|
||||
}
|
||||
|
||||
export interface Activity extends dbModel {
|
||||
watchedId: number;
|
||||
type: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface Watched extends dbModel {
|
||||
id: number;
|
||||
watched: boolean;
|
||||
rating: Rating;
|
||||
rating?: number;
|
||||
content: Content;
|
||||
activity: Activity[];
|
||||
status: WatchedStatus;
|
||||
}
|
||||
|
||||
export interface WatchedAddRequest {
|
||||
contentId: number;
|
||||
contentType: ContentType;
|
||||
rating: Rating;
|
||||
rating?: number;
|
||||
status: WatchedStatus;
|
||||
}
|
||||
|
||||
export interface WatchedUpdateRequest {
|
||||
rating?: Rating;
|
||||
rating?: number;
|
||||
status?: WatchedStatus;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
joined: Date;
|
||||
showsWatched: number;
|
||||
moviesWatched: number;
|
||||
}
|
||||
|
||||
export interface TMDBContentDetails {
|
||||
id: number;
|
||||
backdrop_path: string;
|
||||
genres: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
poster_path: string;
|
||||
homepage: string;
|
||||
popularity: number;
|
||||
overview: string;
|
||||
original_language: string;
|
||||
production_companies: {
|
||||
id: number;
|
||||
logo_path: string;
|
||||
name: string;
|
||||
origin_country: string;
|
||||
}[];
|
||||
production_countries: {
|
||||
iso_3166_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
status: string;
|
||||
tagline: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
spoken_languages: {
|
||||
english_name: string;
|
||||
iso_639_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface TMDBMovieDetails extends TMDBContentDetails {
|
||||
adult: boolean;
|
||||
belongs_to_collection: any;
|
||||
budget: number;
|
||||
imdb_id: string;
|
||||
original_title: string;
|
||||
release_date: string;
|
||||
revenue: number;
|
||||
runtime: number;
|
||||
title: string;
|
||||
video: boolean;
|
||||
}
|
||||
|
||||
export interface TMDBShowDetails extends TMDBContentDetails {
|
||||
created_by: {
|
||||
id: number;
|
||||
credit_id: string;
|
||||
name: string;
|
||||
gender: number;
|
||||
profile_path: string;
|
||||
}[];
|
||||
episode_run_time: number[];
|
||||
first_air_date: string;
|
||||
in_production: boolean;
|
||||
languages: string[];
|
||||
last_air_date: string;
|
||||
last_episode_to_air: {
|
||||
air_date: string;
|
||||
episode_number: number;
|
||||
id: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
production_code: string;
|
||||
season_number: number;
|
||||
still_path: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
};
|
||||
name: string;
|
||||
next_episode_to_air: any;
|
||||
networks: {
|
||||
name: string;
|
||||
id: number;
|
||||
logo_path: string;
|
||||
origin_country: string;
|
||||
}[];
|
||||
number_of_episodes: number;
|
||||
number_of_seasons: number;
|
||||
origin_country: string[];
|
||||
original_name: string;
|
||||
seasons: TMDBShowSeason[];
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface TMDBShowSeason {
|
||||
air_date: string;
|
||||
episode_count: number;
|
||||
id: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
poster_path: string;
|
||||
season_number: number;
|
||||
}
|
||||
|
||||
export interface TMDBSeasonDetails {
|
||||
_id: string;
|
||||
air_date: string;
|
||||
episodes: {
|
||||
air_date: string;
|
||||
episode_number: number;
|
||||
id: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
production_code: string;
|
||||
runtime: number;
|
||||
season_number: number;
|
||||
show_id: number;
|
||||
still_path: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
crew: {
|
||||
department: string;
|
||||
job: string;
|
||||
credit_id: string;
|
||||
adult: boolean;
|
||||
gender: number;
|
||||
id: number;
|
||||
known_for_department: string;
|
||||
name: string;
|
||||
original_name: string;
|
||||
popularity: number;
|
||||
profile_path: string;
|
||||
}[];
|
||||
guest_stars: {
|
||||
character: string;
|
||||
credit_id: string;
|
||||
order: number;
|
||||
adult: boolean;
|
||||
gender: number;
|
||||
id: number;
|
||||
known_for_department: string;
|
||||
name: string;
|
||||
original_name: string;
|
||||
popularity: number;
|
||||
profile_path: string;
|
||||
}[];
|
||||
}[];
|
||||
name: string;
|
||||
overview: string;
|
||||
id: number;
|
||||
poster_path: string;
|
||||
season_number: number;
|
||||
}
|
||||
|
||||
export interface TMDBContentCredits {
|
||||
id: number;
|
||||
cast: {
|
||||
adult: boolean;
|
||||
gender: number;
|
||||
id: number;
|
||||
known_for_department: string;
|
||||
name: string;
|
||||
original_name: string;
|
||||
popularity: number;
|
||||
profile_path: string;
|
||||
cast_id: number;
|
||||
character: string;
|
||||
credit_id: string;
|
||||
order: number;
|
||||
}[];
|
||||
crew: TMDBContentCreditsCrew[];
|
||||
}
|
||||
|
||||
export interface TMDBContentCreditsCrew {
|
||||
adult: boolean;
|
||||
gender: number;
|
||||
id: number;
|
||||
known_for_department: string;
|
||||
name: string;
|
||||
original_name: string;
|
||||
popularity: number;
|
||||
profile_path: string;
|
||||
credit_id: string;
|
||||
department: string;
|
||||
job: string;
|
||||
}
|
||||
|
||||
export interface TMDBPersonDetails {
|
||||
birthday?: string;
|
||||
known_for_department?: string;
|
||||
deathday?: string;
|
||||
id?: number;
|
||||
name?: string;
|
||||
also_known_as?: string[];
|
||||
gender?: number;
|
||||
biography?: string;
|
||||
popularity?: number;
|
||||
place_of_birth?: string;
|
||||
profile_path?: string;
|
||||
adult?: boolean;
|
||||
imdb_id?: string;
|
||||
homepage?: string;
|
||||
}
|
||||
|
||||
export interface TMDBPersonCombinedCredits {
|
||||
id: number;
|
||||
cast: TMDBPersonCombinedCreditsCast[];
|
||||
}
|
||||
|
||||
export interface TMDBPersonCombinedCreditsCast {
|
||||
id: number;
|
||||
original_language: string;
|
||||
episode_count: number;
|
||||
overview: string;
|
||||
origin_country: string[];
|
||||
original_name: string;
|
||||
genre_ids: number[];
|
||||
name: string;
|
||||
media_type: MediaType;
|
||||
poster_path: string;
|
||||
first_air_date: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
character: string;
|
||||
backdrop_path: string;
|
||||
popularity: number;
|
||||
credit_id: string;
|
||||
original_title: string;
|
||||
video: boolean;
|
||||
release_date: string;
|
||||
title: string;
|
||||
adult: boolean;
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+9
-3
@@ -1,6 +1,12 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
plugins: [sveltekit()],
|
||||
define: {
|
||||
__WATCHARR_VERSION__: JSON.stringify(pkg.version)
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user