Compare commits
379 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b21d2bf603 | |||
| 0667f33250 | |||
| 0da264480c | |||
| 622d10e743 | |||
| 7c216f4691 | |||
| b5f9700345 | |||
| 7e03557e56 | |||
| 10a79a31cd | |||
| e41d408cd0 | |||
| 6b22b4a4e2 | |||
| 67c3083edd | |||
| e968b7ff28 | |||
| adfd983a68 | |||
| b32ba86c5d | |||
| ebb0b132bd | |||
| 4f1fbf4a45 | |||
| 74f896a8bc | |||
| 3b1d42c148 | |||
| 7a902212a5 | |||
| 2a718ef6d8 | |||
| f71dc287bd | |||
| 77d2c6f49a | |||
| 3eb454cc93 | |||
| 8a5fd8182f | |||
| aa7a38b2ee | |||
| ed7a780c44 | |||
| 32f72f382e | |||
| 2e0ea4d6c7 | |||
| 2a9a3691a3 | |||
| 49692f5e02 | |||
| 5f06e7b52f | |||
| 1933ca350d | |||
| 337957fb9f | |||
| 458c91d505 | |||
| 5498236b48 | |||
| 0e7cdb0199 | |||
| 7d25b0bf5d | |||
| ccadcf2152 | |||
| 8e0fb190d1 | |||
| 62033c66aa | |||
| 95130fd4fb | |||
| c4cebb717c | |||
| d92dd51bc8 | |||
| 4e4f8f9fcf | |||
| 11a0ec55d5 | |||
| 14b3f38b5d | |||
| 5799cd649b | |||
| d4b873ec7d | |||
| d00ae04074 | |||
| 8955ca7b26 | |||
| 82c6729a22 | |||
| 1929272705 | |||
| 709dead520 | |||
| 7cd348804c | |||
| ba91f65cee | |||
| a09488dbba | |||
| 111b2b1afd | |||
| c0ff3205a6 | |||
| 92815f6907 | |||
| d9257e767f | |||
| 26ec0f592a | |||
| 1e4814d50a | |||
| bbe2123c1e | |||
| d7c2897698 | |||
| 8cd930369d | |||
| 4cea72d8e9 | |||
| e9a4629c2a | |||
| 3e02af9c40 | |||
| 2a5a470347 | |||
| 07e3f5f7a8 | |||
| 1b83c70b5e | |||
| 1e5cdc151b | |||
| dcb21a6e1e | |||
| 0b4f3d5c97 | |||
| 0d8ea359e4 | |||
| 1c44b1812a | |||
| 3d5cf70ec0 | |||
| f1f26d0f57 | |||
| e3fe047afd | |||
| 4afc32f9d1 | |||
| 7ea78f2d57 | |||
| 6092410b20 | |||
| 8f5293a769 | |||
| 6d25356d30 | |||
| dbd86b89c7 | |||
| bf0b4fbb6a | |||
| 5585053b85 | |||
| a61b82e9af | |||
| d4fe6d3653 | |||
| 6d2ab6e7e4 | |||
| c5e6ae702a | |||
| 2a9b0443f5 | |||
| e1e933af16 | |||
| a5fbecd32a | |||
| 98006efb03 | |||
| 926b0f8267 | |||
| cc3f21aa93 | |||
| e311bcd901 | |||
| eb66baba80 | |||
| 09795b4da3 | |||
| 8088184e6b | |||
| 1b6c56dd8f | |||
| 86195eb682 | |||
| f40ffa124c | |||
| 704b6380c7 | |||
| b434665caf | |||
| 3554d4bc60 | |||
| 917ddd5401 | |||
| e7cf21fdb2 | |||
| c932bd188a | |||
| e5d17bee81 | |||
| 65836a1654 | |||
| 0f7bdaffbb | |||
| 30dae4c5c9 | |||
| f67d1641db | |||
| 0ff02e00c9 | |||
| 194081ac2f | |||
| 896f80ebd6 | |||
| 862279e914 | |||
| bdf5cf54af | |||
| 4323ab7086 | |||
| 555b237ed0 | |||
| 5146af2672 | |||
| 4d3da51ae7 | |||
| 9cba877fe6 | |||
| 924ffcb33f | |||
| b638062d36 | |||
| da64b5ca59 | |||
| 8f2d9b0984 | |||
| c2e85ff8b3 | |||
| 3c9d074169 | |||
| 7a254222a1 | |||
| 4662977677 | |||
| 798c80f69b | |||
| d8e4182824 | |||
| ac9fb8c28d | |||
| d0b1b9ed17 | |||
| 548643327d | |||
| a86d2a218a | |||
| 21ecc4cbbf | |||
| 2e19cad3a8 | |||
| 8bffc7380e | |||
| 96720caace | |||
| 6c130952d7 | |||
| 3c1fc0e950 | |||
| d95990fc56 | |||
| 9d0a433121 | |||
| 2bd02439c2 | |||
| 6a1fdd69e3 | |||
| fb7dc5d0f8 | |||
| c3d480d47b | |||
| 274c97bcdd | |||
| d6b5fc9cd3 | |||
| 4de9865a39 | |||
| d278f2cf98 | |||
| 5aec83c2f0 | |||
| fbda3d4b9b | |||
| dde8fad248 | |||
| e8309568bc | |||
| 997e3afb7e | |||
| 891fc51bcd | |||
| ebe42f1d34 | |||
| b799c660ca | |||
| a10f570983 | |||
| f205f96010 | |||
| 735a98e0b2 | |||
| f87aa70a3b | |||
| a2dc418ee6 | |||
| 4c7982f7be | |||
| 43d603c212 | |||
| ebf623ed1b | |||
| d925519beb | |||
| 07b72452e8 | |||
| ab4781bec0 | |||
| 7719b6336c | |||
| e749e94bfb | |||
| c1ceb17ada | |||
| 91dd00b556 | |||
| 60e873b4c0 | |||
| 4333591c9c | |||
| 462a46aabb | |||
| c766d68d49 | |||
| 6b7c38aa7a | |||
| 367c79f29c | |||
| c59eb82a1a | |||
| 3d40e5554f | |||
| 75f091cfa0 | |||
| 1d0abe4982 | |||
| f8490481a5 | |||
| 1adc56f965 | |||
| 8861233aaa | |||
| 3c4718bc45 | |||
| f6fa9e7666 | |||
| 98a7c370a8 | |||
| 37f9bffb13 | |||
| a4573fe52a | |||
| aeaaccda88 | |||
| 3a2ce0c718 | |||
| 2ac80d0b8c | |||
| 3ff5d9836b | |||
| e6d486c2a2 | |||
| c4e591e2f2 | |||
| 0eb1a88103 | |||
| ffe6afe2d2 | |||
| c767572905 | |||
| da803220e4 | |||
| 5c4f023fd8 | |||
| 7e2e64f394 | |||
| 9609909122 | |||
| 7522784f18 | |||
| 8d5379e38b | |||
| 8065349a5c | |||
| 26c84ae7c7 | |||
| f190bffdd0 | |||
| e0f8d753bf | |||
| b6223c7ac8 | |||
| ffd8bbe442 | |||
| bc86b8f6b4 | |||
| 0cb5c01b5e | |||
| 0393966ecc | |||
| b3ba00cd0d | |||
| 5084a5b724 | |||
| 43a484f949 | |||
| 8434e852ce | |||
| e541dc5cdb | |||
| 4a20819803 | |||
| 9a568b9da7 | |||
| 9176d97eee | |||
| 1cbc065b7b | |||
| b4865aa789 | |||
| 0c29927ef6 | |||
| 75e499a070 | |||
| fe589e7de5 | |||
| 9d7d6b0e3a | |||
| 5fb720cca4 | |||
| 2084b42098 | |||
| d26db7759f | |||
| 89311d4487 | |||
| 72dca29d69 | |||
| 36b3bde00e | |||
| 425f0da422 | |||
| 6773736113 | |||
| 51ec248b67 | |||
| 702084c83c | |||
| 2aa5f0c4fa | |||
| 359f5a372a | |||
| 03a8397498 | |||
| 034e6b10e7 | |||
| 8100c5f9a9 | |||
| 95c8f2d5b0 | |||
| be29170f6d | |||
| f02699b089 | |||
| 82b7a3d0f6 | |||
| 8608ad4fd6 | |||
| ab96107797 | |||
| a38291c9b9 | |||
| 67cfd03a1d | |||
| c4fa99b5aa | |||
| 1d904a7188 | |||
| be8cee5378 | |||
| 128a3d4cc6 | |||
| 610fb3d0c0 | |||
| 2dd01f296b | |||
| 6e118085a9 | |||
| 2f3400d4d7 | |||
| da787741d8 | |||
| 97bdf2f89c | |||
| a8c8431715 | |||
| 18e3e333c6 | |||
| f7ddb1e14b | |||
| e5c513161e | |||
| 55235839ff | |||
| e5536c5429 | |||
| 02fc0ccb94 | |||
| 83dcb85e5b | |||
| 155cd21b40 | |||
| f06da5b26e | |||
| e00bfa0187 | |||
| 4a008e4c2f | |||
| 8c84fe29f9 | |||
| 3bd03e4e97 | |||
| 4d65a16584 | |||
| 21b4e49aa0 | |||
| 1bcc521d3a | |||
| 9fa85e3191 | |||
| 55ff90a190 | |||
| 2bf7639ecf | |||
| 2aabd94311 | |||
| 17a61c0248 | |||
| a17c7a4ce5 | |||
| f31009fa5e | |||
| 559ac8945a | |||
| 1ce7f5a938 | |||
| d634956d76 | |||
| 3275ad6620 | |||
| 3ac9e94f89 | |||
| 9562b3fef0 | |||
| ae95329d06 | |||
| 1e81872876 | |||
| 90eb422c9d | |||
| 3bfcb741f1 | |||
| e04d72e565 | |||
| 99aa4e564a | |||
| eac5c5a91e | |||
| 82e9a4c0ea | |||
| 4dd494dc80 | |||
| 4422e83cd7 | |||
| eec6e00dac | |||
| 579736eb10 | |||
| 06fadeffa1 | |||
| 97c87ecbf0 | |||
| e55593d62b | |||
| 2a3b5c8746 | |||
| 52097df861 | |||
| 92637aef3d | |||
| 02f537c5bf | |||
| c63ab3cddf | |||
| cf7457a671 | |||
| 095cc21ea4 | |||
| 983f4efa95 | |||
| 4573d69bbf | |||
| 07cbcb4d41 | |||
| 71e31df2bf | |||
| f969c591c1 | |||
| 6d1e95ff5c | |||
| e7a6a888b1 | |||
| ab9fdddc14 | |||
| 6160e906df | |||
| dafac30df1 | |||
| e98b6a383d | |||
| 679168d88c | |||
| 8679cdf0c6 | |||
| 27e2e98ca3 | |||
| 3f90b2f0ca | |||
| dfa4e81dc2 | |||
| 3d3bfa50bb | |||
| f4a4b7c6d7 | |||
| db1b1bee65 | |||
| 53aa76f009 | |||
| 24fe7f4984 | |||
| 99d9d4866a | |||
| 7b0594a39b | |||
| 628dfef8d1 | |||
| a3e305478b | |||
| 0ca133f002 | |||
| 0586e91b31 | |||
| 3dbcc7b69f | |||
| 784b6f2559 | |||
| d61711ace3 | |||
| c520cd6be0 | |||
| 00a3b7041e | |||
| 6a69fbdab2 | |||
| deb30ebf03 | |||
| 985e5b3d39 | |||
| 2b3dcd57d3 | |||
| 4200b03736 | |||
| 7190a43604 | |||
| 7c77d9c536 | |||
| bd844b17c4 | |||
| 9bf3e6728c | |||
| 78542120da | |||
| 6148773965 | |||
| 1796bc9fb7 | |||
| 86c82181e1 | |||
| 51aec112b4 | |||
| bffb7960cc | |||
| 075b688605 | |||
| 6f10d221cf | |||
| 37fad5b8d3 | |||
| 8c544535c6 | |||
| 23d407fbd6 | |||
| 20ca9ff2be | |||
| 1b8a8ba491 | |||
| e7d93649c2 | |||
| 53b8b1d500 | |||
| 6ae214bd98 | |||
| 7f067c62a4 | |||
| 2e6bfc9985 | |||
| 33ea4cd3fe |
@@ -0,0 +1,5 @@
|
||||
<!-- Make sure your code is formatted by running `npm run format` or using prettier manually. -->
|
||||
|
||||
### Changes made
|
||||
|
||||
<!-- Describe changes made here. If changes are visual a screenshot could be useful! -->
|
||||
@@ -9,7 +9,7 @@ updates:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "ui"
|
||||
|
||||
|
||||
# Server
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/server"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Multi arch support doc/guide:
|
||||
# https://docs.docker.com/build/ci/github-actions/multi-platform/
|
||||
|
||||
name: Development Watcharr Image Release
|
||||
|
||||
on:
|
||||
@@ -18,6 +21,12 @@ jobs:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -30,6 +39,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.image }}:dev
|
||||
labels: ${{ env.image }}:${{ github.sha }}
|
||||
|
||||
@@ -20,6 +20,12 @@ jobs:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -38,6 +44,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Test PR Changes
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
tags-ignore:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
linter_check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Get dependencies
|
||||
run: npm i
|
||||
|
||||
- name: Check for linting/formatting errors
|
||||
run: npm run lint
|
||||
@@ -40,3 +40,8 @@ vite.config.ts.timestamp-*
|
||||
|
||||
# Docker dev volume
|
||||
container_data/
|
||||
data/
|
||||
|
||||
# Build artifacts
|
||||
server/ui
|
||||
server/watcharr
|
||||
|
||||
@@ -12,4 +12,4 @@
|
||||
"program": "${fileDirname}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ FROM golang:1.21-alpine AS server
|
||||
WORKDIR /server
|
||||
COPY server/*.go server/go.* ./
|
||||
COPY server/arr/*.go ./arr/
|
||||
COPY server/game/*.go ./game/
|
||||
|
||||
# Required so we can build with cgo
|
||||
RUN apk update && apk add --no-cache musl-dev gcc build-base
|
||||
|
||||
RUN go mod download && GOOS=linux CGO_ENABLED=1 go build -o ./watcharr
|
||||
# CGO_CFLAGS: https://github.com/mattn/go-sqlite3/issues/1164#issuecomment-1635253695
|
||||
RUN go mod download && GOOS=linux CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go build -o ./watcharr
|
||||
|
||||
# Frontend
|
||||
FROM node:20-alpine AS ui
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
<a href="https://watcharr.app"><img src="https://img.shields.io/website?label=DOCS&style=for-the-badge&url=https%3A%2F%2Fwatcharr.app" /></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>
|
||||
<a href="https://matrix.to/#/#watcharr:matrix.org"><img src="https://img.shields.io/matrix/watcharr%3Amatrix.org?style=for-the-badge&logo=matrix" /></a>
|
||||
</p>
|
||||
|
||||
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'm your new easily self-hosted content watched list. The place you store your watched (or watching, planned, etc) **movies** and **tv shows** (and **anime**), rate them and track their status.
|
||||
|
||||
With [some extra configuration](https://watcharr.app/docs/server_config/game-support-igdb) I can also track your **video games**.
|
||||
|
||||
I am built with Go and Svelte(Kit).
|
||||
|
||||
@@ -28,20 +31,20 @@ Feel free to abuse this demo instance (nicely), which runs on the latest `dev` b
|
||||
|
||||
<p align="center">
|
||||
|
||||
| Homepage | Watched Show Hover |
|
||||
| -------- | ------------------ |
|
||||
| Homepage | Watched Show Hover |
|
||||
| ---------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| <img src="./screenshot/homepage.png" alt="Watched List" /> | <img src="./screenshot/homepage-poster-hover.png" alt="Watched List" /> |
|
||||
|
||||
| Watched Show Status Change | Movie Details |
|
||||
| -------------------------- | ------------- |
|
||||
| Watched Show Status Change | Movie Details |
|
||||
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| <img src="./screenshot/homepage-poster-change-status.png" alt="Changing Show Status" /> | <img src="./screenshot/content-details-page.png" alt="Content Details Page" /> |
|
||||
|
||||
| User Profile | Discover |
|
||||
| ------------ | -------- |
|
||||
| User Profile | Discover |
|
||||
| ------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| <img src="./screenshot/user-profile.png" alt="User Profile Page" /> | <img src="./screenshot/discover-page.png" alt="Discover Page" /> |
|
||||
|
||||
| Dark Homepage | Dark Content Details |
|
||||
| ------------- | -------------------- |
|
||||
| Dark Homepage | Dark Content Details |
|
||||
| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| <img src="./screenshot/homepage-dark.png" alt="Dark Theme Homepage" /> | <img src="./screenshot/content-details-page-dark.png" alt="Dark Theme Content Page" /> |
|
||||
|
||||
</p>
|
||||
@@ -53,3 +56,5 @@ Feel free to abuse this demo instance (nicely), which runs on the latest `dev` b
|
||||
# Getting Help
|
||||
|
||||
If something isn't working for you or you are stuck, [creating an issue](https://github.com/sbondCo/Watcharr/issues/new) is the best way to get help! Every type of issue is accepted, so don't be afraid to ask anything!
|
||||
|
||||
You can also [join our space on Matrix](https://matrix.to/#/#watcharr:matrix.org) for support.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module.exports = {
|
||||
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
|
||||
presets: [require.resolve("@docusaurus/core/lib/babel/preset")]
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ services:
|
||||
volumes:
|
||||
# Contains all of watcharr data (database & cache)
|
||||
- ./data:/data
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
:::danger first account
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Server Configuration",
|
||||
"position": 2,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Learn how to configure your server."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Games Support (via IGDB/Twitch)
|
||||
|
||||
## Overview
|
||||
|
||||
Game support in Watcharr, currently, must be enabled by you.
|
||||
|
||||
This involves setting up a Twitch developer account (phone number needed for 2FA which is required) and entering application credentials into Watcharr, that can then be used for looking up game info via IGDB.
|
||||
|
||||
P.S. These steps are stolen from [igdb api docs](https://api-docs.igdb.com/#account-creation), I've tried to make it easier to follow with screenshots.
|
||||
|
||||
## Steps
|
||||
|
||||
### Setup On Twitch
|
||||
|
||||
1. Sign up for a [Twitch](https://dev.twitch.tv/login) account.
|
||||
Click login with twitch, then create an account in the Sign Up tab.
|
||||
2. Enable two factor authentication in your [account Security settings](https://www.twitch.tv/settings/security) (currently requires a phone number).
|
||||
3. [Register a new Application in the Twitch Developer Console](https://dev.twitch.tv/console/apps/create).
|
||||
These settings will do (name can be anything, we don't use OAuth, but a redirect url is required; localhost will do):
|
||||
|
||||

|
||||
|
||||
4. [Click manage](https://dev.twitch.tv/console/apps) on your newly created application.
|
||||
5. Click 'New Secret' to generate a new Client Secret.
|
||||
6. Take a note of your Client ID and new Client Secret (by leaving this tab open, until we are ready to copy the values into Watcharr).
|
||||
|
||||
### Setup On Watcharr
|
||||
|
||||
1. Login to Watcharr with an Admin account and navigate to server settings.
|
||||
|
||||

|
||||
|
||||
2. Scroll down and click on the Twitch service.
|
||||
|
||||

|
||||
|
||||
3. Enter your Client ID and Client Secret (from Twitch Application page, step 6 on twitch setup steps)
|
||||
|
||||

|
||||
|
||||
4. Click save, and start searching for your favourite games!
|
||||
|
||||

|
||||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 675 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
@@ -1,8 +1,9 @@
|
||||
// @ts-check
|
||||
// Note: type annotations allow type checking and IDEs autocompletion
|
||||
|
||||
const lightCodeTheme = require("prism-react-renderer/themes/github");
|
||||
const darkCodeTheme = require("prism-react-renderer/themes/dracula");
|
||||
const { themes } = require("prism-react-renderer");
|
||||
const lightTheme = themes.github;
|
||||
const darkTheme = themes.dracula;
|
||||
|
||||
/** @type {import('@docusaurus/types').Config} */
|
||||
const config = {
|
||||
@@ -53,7 +54,10 @@ const config = {
|
||||
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc"
|
||||
},
|
||||
theme: {
|
||||
customCss: require.resolve("./src/css/custom.css")
|
||||
customCss: [
|
||||
"./node_modules/viewerjs/dist/viewer.css",
|
||||
require.resolve("./src/css/custom.css")
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -79,6 +83,11 @@ const config = {
|
||||
{
|
||||
type: "docsVersionDropdown"
|
||||
},
|
||||
{
|
||||
href: "https://beta.watcharr.app",
|
||||
label: "Demo",
|
||||
position: "right"
|
||||
},
|
||||
{
|
||||
href: "https://github.com/sbondCo/Watcharr",
|
||||
label: "GitHub",
|
||||
@@ -87,8 +96,8 @@ const config = {
|
||||
]
|
||||
},
|
||||
prism: {
|
||||
theme: lightCodeTheme
|
||||
// darkTheme: darkCodeTheme
|
||||
theme: lightTheme,
|
||||
darkTheme: darkTheme
|
||||
},
|
||||
colorMode: {
|
||||
defaultMode: "light",
|
||||
|
||||
@@ -14,16 +14,17 @@
|
||||
"write-heading-ids": "docusaurus write-heading-ids"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "2.4.3",
|
||||
"@docusaurus/preset-classic": "2.4.3",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"clsx": "^1.2.1",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
"@docusaurus/core": "3.1.1",
|
||||
"@docusaurus/preset-classic": "3.1.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.1.0",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"viewerjs": "^1.11.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "2.4.3"
|
||||
"@docusaurus/module-type-aliases": "3.1.1"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -38,6 +39,6 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14"
|
||||
"node": ">=18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
|
||||
const sidebars = {
|
||||
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||
tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
|
||||
tutorialSidebar: [{ type: "autogenerated", dirName: "." }]
|
||||
|
||||
// But you can create a sidebar manually
|
||||
/*
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useEffect } from "react";
|
||||
import styles from "./styles.module.css";
|
||||
import clsx from "clsx";
|
||||
import Viewer from "viewerjs";
|
||||
|
||||
function TwoColFeat({ title, description, imgSrc }) {
|
||||
return (
|
||||
<div className={clsx(styles.twoCol)}>
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<img src={imgSrc} alt="" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomepageTwoColFeats() {
|
||||
useEffect(() => {
|
||||
const ctr = document.getElementById("homepage-two-col-feats");
|
||||
console.debug("Starting image viewer", ctr);
|
||||
new Viewer(ctr, {
|
||||
navbar: 0,
|
||||
toolbar: {
|
||||
zoomIn: 2,
|
||||
zoomOut: 2,
|
||||
next: 2,
|
||||
prev: 2
|
||||
},
|
||||
title: 0,
|
||||
className: "twoColViewer"
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section id="homepage-two-col-feats" className={styles.twoColFeats}>
|
||||
<TwoColFeat
|
||||
title="Neat UI"
|
||||
description="We just look lightweight."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Easy To Use"
|
||||
description="Not much to it, other than ease of use."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage-poster-change-status.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="In-Depth Details"
|
||||
description="About your favourite content."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Personal Stats and Configuration"
|
||||
description="Make it how you like."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/user-profile.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Discovery"
|
||||
description="Find the latest trending content."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/discover-page.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Dark Theme"
|
||||
description="For you who are still unsatisfied. ;("
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page-dark.png"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.twoColFeats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
max-width: 1300px;
|
||||
}
|
||||
|
||||
.twoCol {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
width: 100%;
|
||||
padding: 2.5rem 5rem;
|
||||
gap: 50px;
|
||||
background-color: rgb(236, 236, 236);
|
||||
}
|
||||
|
||||
.twoCol:nth-child(even) {
|
||||
flex-flow: row-reverse;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.twoCol > div {
|
||||
width: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.twoCol > div > img {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0px 0px 5px rgb(121, 121, 121);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 850px) {
|
||||
.twoCol,
|
||||
.twoCol:nth-child(even) {
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.twoCol > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.twoCol > div > img {
|
||||
max-width: 500px;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
|
||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||
[data-theme='dark'] {
|
||||
[data-theme="dark"] {
|
||||
--ifm-color-primary: #25c2a0;
|
||||
--ifm-color-primary-dark: #21af90;
|
||||
--ifm-color-primary-darker: #1fa588;
|
||||
@@ -28,3 +28,17 @@
|
||||
--ifm-color-primary-lightest: #4fddbf;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.twoColViewer .viewer-close {
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
/* Stupid poopin fix for viewerjs because whatever is bundling the css
|
||||
isn't keeping the order of this correctly. All this time wasted and it
|
||||
was just opaque ;( BUT ONLY IN THE BUILD AAAAAAAAAAAAAAAAAAAAAA */
|
||||
.viewer-in {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import LogoCol from "@site/static/img/logo-col.png";
|
||||
|
||||
import styles from "./index.module.css";
|
||||
import clsx from "clsx";
|
||||
import { HomepageTwoColFeats } from "../components/TwoColFeat";
|
||||
|
||||
function HomepageHeader() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
@@ -45,6 +46,7 @@ export default function Home() {
|
||||
<HomepageHeader />
|
||||
<main>
|
||||
<HomepageFeatures />
|
||||
<HomepageTwoColFeats />
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,13 @@ 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:
|
||||
- ./container_data:/data
|
||||
# Contains all of watcharr data (database & cache)
|
||||
- ./data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "1.22.0",
|
||||
"version": "1.35.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -13,25 +13,29 @@
|
||||
"server": "cd ./server && MODE=DEV go run ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^1.3.1",
|
||||
"@sveltejs/kit": "^1.27.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.11.0",
|
||||
"@typescript-eslint/parser": "^6.11.0",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-svelte": "^2.35.0",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-svelte": "^3.0.3",
|
||||
"svelte": "^4.2.3",
|
||||
"svelte-check": "^3.5.2",
|
||||
"@sveltejs/adapter-node": "^4.0.1",
|
||||
"@sveltejs/kit": "^2.5.3",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@vite-pwa/sveltekit": "^0.3.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-svelte": "^3.2.1",
|
||||
"svelte": "^4.2.10",
|
||||
"svelte-check": "^3.6.4",
|
||||
"svelte-eslint-parser": "^0.33.1",
|
||||
"svelte-preprocess": "^5.1.0",
|
||||
"svelte-preprocess": "^5.1.3",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^4.5.0"
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.6"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.1"
|
||||
"axios": "^1.6.7",
|
||||
"blurhash": "^2.0.5",
|
||||
"papaparse": "^5.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 523 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 371 KiB After Width: | Height: | Size: 305 KiB |
|
Before Width: | Height: | Size: 766 KiB After Width: | Height: | Size: 661 KiB |
|
Before Width: | Height: | Size: 948 KiB After Width: | Height: | Size: 820 KiB |
|
Before Width: | Height: | Size: 866 KiB After Width: | Height: | Size: 743 KiB |
|
Before Width: | Height: | Size: 877 KiB After Width: | Height: | Size: 752 KiB |
|
Before Width: | Height: | Size: 945 KiB After Width: | Height: | Size: 816 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 36 KiB |
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -10,17 +11,31 @@ import (
|
||||
type ActivityType string
|
||||
|
||||
var (
|
||||
ADDED_WATCHED ActivityType = "ADDED_WATCHED"
|
||||
REMOVED_WATCHED ActivityType = "REMOVED_WATCHED"
|
||||
RATING_CHANGED ActivityType = "RATING_CHANGED"
|
||||
STATUS_CHANGED ActivityType = "STATUS_CHANGED"
|
||||
THOUGHTS_CHANGED ActivityType = "THOUGHTS_CHANGED"
|
||||
THOUGHTS_REMOVED ActivityType = "THOUGHTS_REMOVED"
|
||||
IMPORTED_WATCHED ActivityType = "IMPORTED_WATCHED"
|
||||
SEASON_ADDED ActivityType = "SEASON_ADDED"
|
||||
SEASON_REMOVED ActivityType = "SEASON_REMOVED"
|
||||
SEASON_RATING_CHANGED ActivityType = "SEASON_RATING_CHANGED"
|
||||
SEASON_STATUS_CHANGED ActivityType = "SEASON_STATUS_CHANGED"
|
||||
ADDED_WATCHED ActivityType = "ADDED_WATCHED"
|
||||
REMOVED_WATCHED ActivityType = "REMOVED_WATCHED"
|
||||
RATING_CHANGED ActivityType = "RATING_CHANGED"
|
||||
STATUS_CHANGED ActivityType = "STATUS_CHANGED"
|
||||
THOUGHTS_CHANGED ActivityType = "THOUGHTS_CHANGED"
|
||||
THOUGHTS_REMOVED ActivityType = "THOUGHTS_REMOVED"
|
||||
IMPORTED_WATCHED ActivityType = "IMPORTED_WATCHED"
|
||||
IMPORTED_WATCHED_JF ActivityType = "IMPORTED_WATCHED_JF"
|
||||
IMPORTED_WATCHED_PLEX ActivityType = "IMPORTED_WATCHED_PLEX"
|
||||
IMPORTED_RATING ActivityType = "IMPORTED_RATING" // Imported rating, but with no rating acts as original import of content to old platform (where they are importing from) activity
|
||||
IMPORTED_ADDED_WATCHED ActivityType = "IMPORTED_ADDED_WATCHED" // Imported watched date, so we can save the original watch dates of content from users old platform (where they are importing from).
|
||||
IMPORTED_ADDED_WATCHED_JF ActivityType = "IMPORTED_ADDED_WATCHED_JF"
|
||||
IMPORTED_ADDED_WATCHED_PLEX ActivityType = "IMPORTED_ADDED_WATCHED_PLEX"
|
||||
SEASON_ADDED ActivityType = "SEASON_ADDED"
|
||||
SEASON_ADDED_JF ActivityType = "SEASON_ADDED_JF"
|
||||
SEASON_ADDED_PLEX ActivityType = "SEASON_ADDED_PLEX"
|
||||
SEASON_REMOVED ActivityType = "SEASON_REMOVED"
|
||||
SEASON_RATING_CHANGED ActivityType = "SEASON_RATING_CHANGED"
|
||||
SEASON_STATUS_CHANGED ActivityType = "SEASON_STATUS_CHANGED"
|
||||
EPISODE_ADDED ActivityType = "EPISODE_ADDED"
|
||||
EPISODE_ADDED_JF ActivityType = "EPISODE_ADDED_JF"
|
||||
EPISODE_ADDED_PLEX ActivityType = "EPISODE_ADDED_PLEX"
|
||||
EPISODE_REMOVED ActivityType = "EPISODE_REMOVED"
|
||||
EPISODE_RATING_CHANGED ActivityType = "EPISODE_RATING_CHANGED"
|
||||
EPISODE_STATUS_CHANGED ActivityType = "EPISODE_STATUS_CHANGED"
|
||||
)
|
||||
|
||||
type Activity struct {
|
||||
@@ -35,12 +50,19 @@ type Activity struct {
|
||||
// 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"`
|
||||
// Custom date for the activity, that the user can define.
|
||||
CustomDate *time.Time `json:"customDate,omitempty"`
|
||||
}
|
||||
|
||||
type ActivityAddRequest struct {
|
||||
WatchedID uint `json:"watchedId" binding:"required"`
|
||||
Type ActivityType `json:"type" binding:"required"`
|
||||
Data string `json:"data" binding:"required"`
|
||||
WatchedID uint `json:"watchedId" binding:"required"`
|
||||
Type ActivityType `json:"type" binding:"required"`
|
||||
Data string `json:"data" binding:"required"`
|
||||
CustomDate *time.Time `json:"customDate,omitempty"`
|
||||
}
|
||||
|
||||
type ActivityUpdateRequest struct {
|
||||
CustomDate time.Time `json:"customDate" binding:"required"`
|
||||
}
|
||||
|
||||
func getActivity(db *gorm.DB, userId uint, watchedId uint) ([]Activity, error) {
|
||||
@@ -57,7 +79,7 @@ func addActivity(db *gorm.DB, userId uint, ar ActivityAddRequest) (Activity, err
|
||||
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}
|
||||
activity := Activity{UserID: userId, WatchedID: ar.WatchedID, Type: ar.Type, Data: ar.Data, CustomDate: ar.CustomDate}
|
||||
res := db.Create(&activity)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error adding activity to database", "error", res.Error.Error())
|
||||
@@ -66,3 +88,39 @@ func addActivity(db *gorm.DB, userId uint, ar ActivityAddRequest) (Activity, err
|
||||
slog.Debug("Adding activity", "added_activity", activity)
|
||||
return activity, nil
|
||||
}
|
||||
|
||||
func updateActivity(db *gorm.DB, userId uint, id uint, activityUpdateRequest ActivityUpdateRequest) error {
|
||||
if id == 0 {
|
||||
return errors.New("id must be set to update an activity")
|
||||
}
|
||||
if activityUpdateRequest.CustomDate.IsZero() {
|
||||
return errors.New("customDate must be set to update an activity")
|
||||
}
|
||||
res := db.Model(&Activity{}).Where("user_id = ? AND id = ?", userId, id).Update("custom_date", activityUpdateRequest.CustomDate)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error updating activity in database", "error", res.Error.Error())
|
||||
return errors.New("failed updating activity in database")
|
||||
}
|
||||
if res.RowsAffected < 1 {
|
||||
slog.Error("No activities were updated. This may be because the activity doesn't exist or is not owned by the calling user.")
|
||||
return errors.New("failed updating activity in database")
|
||||
}
|
||||
slog.Debug("Updating activity", "updated_activity", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteActivity(db *gorm.DB, userId uint, id uint) error {
|
||||
if id == 0 {
|
||||
return errors.New("an id must be provided to delete an activity")
|
||||
}
|
||||
res := db.Where("user_id = ?", userId).Delete(&Activity{}, id)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error deleting activity in database", "error", res.Error.Error())
|
||||
return errors.New("failed deleting activity in database")
|
||||
}
|
||||
if res.RowsAffected < 1 {
|
||||
slog.Error("No activities were deleted. This may be because the activity doesn't exist or is not owned by the calling user.")
|
||||
return errors.New("failed deleting activity from database")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,6 +29,7 @@ type UserType uint8
|
||||
var (
|
||||
// Assume watcharr user if none of these...
|
||||
JELLYFIN_USER UserType = 1
|
||||
PLEX_USER UserType = 2
|
||||
)
|
||||
|
||||
// User Perms
|
||||
@@ -46,6 +48,9 @@ type User struct {
|
||||
GormModel
|
||||
Username string `gorm:"uniqueIndex:usr_name_to_type;not null" json:"username" binding:"required"`
|
||||
Password string `gorm:"not null" json:"password" binding:"required"`
|
||||
AvatarID uint `json:"-"`
|
||||
Avatar Image `json:"avatar"`
|
||||
Bio string `json:"bio"`
|
||||
// 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"`
|
||||
@@ -53,19 +58,60 @@ type User struct {
|
||||
ThirdPartyID string `json:"-"`
|
||||
// Auth token from third party (jellyfin)
|
||||
ThirdPartyAuth string `json:"-"`
|
||||
Watched []Watched
|
||||
Permissions int `gorm:"default:1" json:"-"`
|
||||
// Users third party integrations (minus jellyfin for now)
|
||||
UserServices []UserServices `json:"-"`
|
||||
Watched []Watched
|
||||
Permissions int `gorm:"default:1" json:"-"`
|
||||
// All user settings cols, in another struct for reusability
|
||||
UserSettings
|
||||
}
|
||||
|
||||
func (u *User) GetSafe() PublicUser {
|
||||
return PublicUser{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Avatar: u.Avatar,
|
||||
Bio: u.Bio,
|
||||
}
|
||||
}
|
||||
|
||||
// This struct uses pointer to the values, so in update user settings,
|
||||
// we can tell which setting is being updated (if not nil..).
|
||||
type UserSettings struct {
|
||||
// Is profile private
|
||||
Private *bool `gorm:"default:false" json:"private"`
|
||||
// Are watched list content thoughts public (profile must also be public is false)
|
||||
PrivateThoughts *bool `gorm:"default:false" json:"privateThoughts"`
|
||||
// If ui 'spoilers' should be shown
|
||||
HideSpoilers *bool `gorm:"default:false" json:"hideSpoilers"`
|
||||
// If user wants previously watched items to show in 'Finished' filter,
|
||||
// even if the watched item state has since been changed.
|
||||
// Also if user wants to show in watched stats.
|
||||
IncludePreviouslyWatched *bool `gorm:"default:false" json:"includePreviouslyWatched"`
|
||||
}
|
||||
|
||||
// Holds third party service auth tokens for users.
|
||||
// Each service may use the fields in their own way.
|
||||
// Unique index applied between service name and clientID
|
||||
// to ensure no duplicates (no need to apply it against
|
||||
// user_id, no accounts should share an integration).
|
||||
//
|
||||
// Plex:
|
||||
// - AuthToken : Used for requests against plex.tv
|
||||
// - AuthToken2 : Used for requests against home plex server.
|
||||
type UserServices struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// Service/integration name
|
||||
Name string `gorm:"uniqueIndex:svc_name_to_cltid;not null;" json:"-"`
|
||||
// The users id on the third party service
|
||||
ClientID string `gorm:"uniqueIndex:svc_name_to_cltid;not null;" json:"-"`
|
||||
AuthToken string `gorm:"not null;" json:"-"`
|
||||
// Second auth token, generic name so future services can use it without extra confusion.
|
||||
// Ex: We require a second auth token for use with our local server for Plex.
|
||||
AuthToken2 string `json:"-"`
|
||||
UserID uint `gorm:"not null;" json:"-"`
|
||||
}
|
||||
|
||||
// We use a separate struct for registration to avoid confusion
|
||||
@@ -111,6 +157,16 @@ type ArgonParams struct {
|
||||
keyLength uint32
|
||||
}
|
||||
|
||||
func GetPassArgonParams() *ArgonParams {
|
||||
return &ArgonParams{
|
||||
memory: 64 * 1024,
|
||||
iterations: 3,
|
||||
parallelism: 2,
|
||||
saltLength: 16,
|
||||
keyLength: 32,
|
||||
}
|
||||
}
|
||||
|
||||
type TokenClaims struct {
|
||||
UserID uint `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
@@ -118,6 +174,11 @@ type TokenClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type UserPasswordUpdateRequest struct {
|
||||
OldPassword string `json:"oldPassword" binding:"required"`
|
||||
NewPassword string `json:"newPassword" binding:"required"`
|
||||
}
|
||||
|
||||
// Auth middleware
|
||||
// If db is passed, extra user info from the database will be fetched.
|
||||
func AuthRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
@@ -200,13 +261,7 @@ func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthRespon
|
||||
}
|
||||
var user User = User{Username: ur.Username, Password: ur.Password}
|
||||
slog.Info("A user is registering", "username", user.Username)
|
||||
hash, err := hashPassword(user.Password, &ArgonParams{
|
||||
memory: 64 * 1024,
|
||||
iterations: 3,
|
||||
parallelism: 2,
|
||||
saltLength: 16,
|
||||
keyLength: 32,
|
||||
})
|
||||
hash, err := hashPassword(user.Password, GetPassArgonParams())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -223,7 +278,7 @@ func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthRespon
|
||||
res := db.Create(&user)
|
||||
if res.Error != nil {
|
||||
// If error is because unique contraint failed.. user already exists
|
||||
if strings.Contains(res.Error.Error(), "UNIQUE") {
|
||||
if res.Error == gorm.ErrDuplicatedKey {
|
||||
slog.Error("Registration failed", "error", res.Error.Error(), "error_pretty", "User already exists")
|
||||
return AuthResponse{}, errors.New("User already exists")
|
||||
}
|
||||
@@ -342,7 +397,7 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
}
|
||||
|
||||
dbUser := new(User)
|
||||
dbRes := db.Where("third_party_id = ?", resp.User.ID).Take(&dbUser)
|
||||
dbRes := db.Where("third_party_id = ? AND type = ?", resp.User.ID, JELLYFIN_USER).Take(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
if errors.Is(dbRes.Error, gorm.ErrRecordNotFound) {
|
||||
// Record not found, so we should create the user
|
||||
@@ -376,6 +431,79 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
|
||||
// Login via Plex.
|
||||
func loginPlex(lr *PlexLoginRequest, db *gorm.DB) (AuthResponse, error) {
|
||||
if Config.PLEX_HOST == "" || Config.PLEX_MACHINE_ID == "" {
|
||||
slog.Error("Request made to login via Plex, but Plex authentication is disabled")
|
||||
return AuthResponse{}, errors.New("plex login not enabled")
|
||||
}
|
||||
slog.Debug("A Plex User Is Logging In")
|
||||
account, err := fetchPlexAccountFromToken(lr.AuthToken)
|
||||
if err != nil {
|
||||
slog.Error("loginPlex: Could not fetch Plex account", "error", err)
|
||||
return AuthResponse{}, errors.New("could not fetch plex acount")
|
||||
}
|
||||
if account.Username == "" || account.Id == 0 {
|
||||
slog.Error("loginPlex: Username or id missing from account response:", "username", account.Username, "id", account.Id)
|
||||
return AuthResponse{}, errors.New("data is missing from the plex account response")
|
||||
}
|
||||
dbUser := new(User)
|
||||
userIdQ := db.Select("user_id").Where("name = ? AND client_id = ?", "plex", account.Id).Table("user_services")
|
||||
dbRes := db.Where("type = ?", PLEX_USER).Where("id = (?)", userIdQ).Preload("UserServices").Take(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
if errors.Is(dbRes.Error, gorm.ErrRecordNotFound) {
|
||||
slog.Debug("loginPlex: New plex user attempted login.. creating Watcharr account now.")
|
||||
if err := plexUserHasAccessToPlexHost(lr.AuthToken); err != nil {
|
||||
slog.Error("loginPlex: Cannot register Plex user. Failed to verify they have access to our home plex server.", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to verify plex access")
|
||||
}
|
||||
dbUser.Username = account.Username
|
||||
dbUser.Type = PLEX_USER
|
||||
homeAuthToken, err := getPlexHomeServerAuthToken(lr.AuthToken, lr.ClientIdentifier)
|
||||
if err != nil {
|
||||
slog.Error("loginPlex: Failed to get home server auth token for the new user! User will still be created, a re-login may fix this issue.", "error", err)
|
||||
}
|
||||
dbUser.UserServices = append(dbUser.UserServices, UserServices{
|
||||
Name: "plex",
|
||||
ClientID: strconv.FormatUint(account.Id, 10),
|
||||
AuthToken: lr.AuthToken,
|
||||
AuthToken2: homeAuthToken,
|
||||
})
|
||||
dbRes = db.Create(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
slog.Error("loginPlex: Failed to create new user in db from plex response", "error", dbRes.Error)
|
||||
return AuthResponse{}, errors.New("failed to create new user from plex")
|
||||
}
|
||||
} else {
|
||||
slog.Error("loginPlex: Failed to select user from database for login", "error", dbRes.Error)
|
||||
return AuthResponse{}, errors.New("failed to locate user")
|
||||
}
|
||||
} else {
|
||||
// If user exists.. update their access tokens in db
|
||||
homeAuthToken, err := getPlexHomeServerAuthToken(lr.AuthToken, lr.ClientIdentifier)
|
||||
if err != nil {
|
||||
slog.Error("loginPlex: Failed to get home server auth token!", "error", err)
|
||||
}
|
||||
for i, v := range dbUser.UserServices {
|
||||
if v.Name == "plex" {
|
||||
slog.Info("loginPlex: Found plex user service.. attemping to update")
|
||||
dbUser.UserServices[i].AuthToken = lr.AuthToken
|
||||
if homeAuthToken != "" {
|
||||
dbUser.UserServices[i].AuthToken2 = homeAuthToken
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
db.Save(&dbUser.UserServices)
|
||||
}
|
||||
token, err := signJWT(dbUser)
|
||||
if err != nil {
|
||||
slog.Error("loginPlex: Failed to sign new jwt", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
}
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
|
||||
func useAdminToken(req *UseAdminTokenRequest, db *gorm.DB, userId uint) error {
|
||||
var dbToken Token
|
||||
resp := db.Where("value = ?", req.Token).Take(&dbToken)
|
||||
@@ -525,3 +653,38 @@ func hasPermission(perms int, reqPerm int) bool {
|
||||
}
|
||||
return (perms & reqPerm) == reqPerm
|
||||
}
|
||||
|
||||
func userChangePassword(db *gorm.DB, pwds UserPasswordUpdateRequest, userId uint) error {
|
||||
slog.Debug("userChangePassword request running", "user_id", userId)
|
||||
user := new(User)
|
||||
res := db.Where("id = ?", userId).Select("password").Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("userChangePassword failed - failed to retrieve user from database", "user_id", userId, "error", res.Error)
|
||||
return errors.New("failed to retrieve user")
|
||||
}
|
||||
slog.Debug("userChangePassword user found", "user_id", userId)
|
||||
match, err := compareHash(pwds.OldPassword, user.Password)
|
||||
if err != nil {
|
||||
slog.Error("userChangePassword failed - failed to compare passwords", "user_id", userId, "error", err)
|
||||
return errors.New("failed to compare passwords")
|
||||
}
|
||||
if !match {
|
||||
slog.Error("userChangePassword failed - current password hash doesn't match password hash in database", "user_id", userId, "error", err)
|
||||
return errors.New("current password provided doesn't match password in database")
|
||||
}
|
||||
slog.Debug("userChangePassword hash for current password matches hash in the database", "user_id", userId)
|
||||
slog.Debug("userChangePassword hashing new password", "user_id", userId)
|
||||
hash, err := hashPassword(pwds.NewPassword, GetPassArgonParams())
|
||||
if err != nil {
|
||||
slog.Error("userChangePassword failed - failed to hash new password", "user_id", userId, "error", err)
|
||||
return errors.New("failed to hash new password")
|
||||
}
|
||||
slog.Debug("userChangePassword new password hashed", "user_id", userId)
|
||||
if err := db.Model(&User{}).Where("id = ?", userId).Update("password", hash).Error; err != nil {
|
||||
slog.Error("userChangePassword failed - failed to update password in database", "user_id", userId, "error", err)
|
||||
return errors.New("failed to update password")
|
||||
} else {
|
||||
slog.Debug("userChangePassword password updated", "user_id", userId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,8 +6,14 @@ import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/game"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var DataPath = "./data"
|
||||
|
||||
type ServerConfig struct {
|
||||
// Used to sign JWT tokens. Make sure to make
|
||||
// it strong, just like a very long, complicated password.
|
||||
@@ -19,14 +25,24 @@ type ServerConfig struct {
|
||||
|
||||
// Enable/disable signup functionality.
|
||||
// Set to `false` to disable registering an account.
|
||||
SIGNUP_ENABLED bool `json:",omitempty"`
|
||||
SIGNUP_ENABLED bool
|
||||
|
||||
// Optional: Provide your own TMDB API Key.
|
||||
// If unprovided, the default Watcharr API key will be used.
|
||||
TMDB_KEY string `json:",omitempty"`
|
||||
|
||||
// Optional: Point to Plex install to enable plex features.
|
||||
PLEX_HOST string `json:",omitempty"`
|
||||
|
||||
// Optional: Machine identifier of your Plex server.
|
||||
// This is used to ensure only users of your Plex server
|
||||
// can use this Watcharr instance.
|
||||
// Will be fetched automatically when PLEX_HOST is provided via web ui.
|
||||
PLEX_MACHINE_ID string `json:",omitempty"`
|
||||
|
||||
SONARR []SonarrSettings `json:",omitempty"`
|
||||
RADARR []RadarrSettings `json:",omitempty"`
|
||||
TWITCH game.IGDB `json:",omitempty"`
|
||||
|
||||
// Enable/disable debug logging. Useful for when trying
|
||||
// to figure out exactly what the server is doing at a point
|
||||
@@ -43,21 +59,24 @@ type ServerConfig struct {
|
||||
// not editable on frontend, so not needed).
|
||||
func (c *ServerConfig) GetSafe() ServerConfig {
|
||||
return ServerConfig{
|
||||
SIGNUP_ENABLED: c.SIGNUP_ENABLED,
|
||||
JELLYFIN_HOST: c.JELLYFIN_HOST,
|
||||
TMDB_KEY: c.TMDB_KEY,
|
||||
DEBUG: c.DEBUG,
|
||||
SONARR: c.SONARR, // Dont act safe, this contains sonarr api key, needed for config
|
||||
RADARR: c.RADARR, // Dont act safe, this contains radarr api key, needed for config
|
||||
SIGNUP_ENABLED: c.SIGNUP_ENABLED,
|
||||
JELLYFIN_HOST: c.JELLYFIN_HOST,
|
||||
TMDB_KEY: c.TMDB_KEY,
|
||||
PLEX_HOST: c.PLEX_HOST,
|
||||
PLEX_MACHINE_ID: c.PLEX_MACHINE_ID,
|
||||
DEBUG: c.DEBUG,
|
||||
SONARR: c.SONARR, // Dont act safe, this contains sonarr api key, needed for config
|
||||
RADARR: c.RADARR, // Dont act safe, this contains radarr api key, needed for config
|
||||
TWITCH: game.IGDB{
|
||||
ClientID: c.TWITCH.ClientID,
|
||||
ClientSecret: c.TWITCH.ClientSecret,
|
||||
}, // Dont act safe, this contains twitch secrets, needed for config
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// Our server config.. set defaults here, then `readConfig`
|
||||
// will overwrite if provided in watcharr.json cfg file.
|
||||
Config = ServerConfig{
|
||||
SIGNUP_ENABLED: true,
|
||||
}
|
||||
// Our server config.. `readConfig` will overwrite from watcharr.json cfg file.
|
||||
Config = ServerConfig{}
|
||||
)
|
||||
|
||||
// Read config file
|
||||
@@ -91,19 +110,22 @@ func initFromConfig() error {
|
||||
}
|
||||
|
||||
// Generate new barebones watcharr.json config file.
|
||||
// Currently only JWT_SECRET is required, so this method
|
||||
// generates a secret.
|
||||
// Generates a JWT_SECRET and set default config.
|
||||
func generateConfig() error {
|
||||
key, err := generateString(64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := ServerConfig{JWT_SECRET: key}
|
||||
cfg := ServerConfig{
|
||||
JWT_SECRET: key,
|
||||
// Other defaults..
|
||||
SIGNUP_ENABLED: true,
|
||||
}
|
||||
barej, err := json.MarshalIndent(cfg, "", "\t")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Config.JWT_SECRET = cfg.JWT_SECRET
|
||||
Config = cfg
|
||||
return os.WriteFile("./data/watcharr.json", barej, 0755)
|
||||
}
|
||||
|
||||
@@ -140,6 +162,7 @@ func writeConfig() error {
|
||||
type ServerFeatures struct {
|
||||
Sonarr bool `json:"sonarr"`
|
||||
Radarr bool `json:"radarr"`
|
||||
Games bool `json:"games"`
|
||||
}
|
||||
|
||||
// Get enabled server functionality from Config.
|
||||
@@ -147,6 +170,9 @@ type ServerFeatures struct {
|
||||
// which btns should be shown, etc.
|
||||
func getEnabledFeatures(userPerms int) ServerFeatures {
|
||||
var f ServerFeatures
|
||||
if Config.TWITCH.ClientID != nil && Config.TWITCH.ClientSecret != nil {
|
||||
f.Games = true
|
||||
}
|
||||
// https://github.com/sbondCo/Watcharr/issues/211
|
||||
// Currently requesting permissions have not been setup, only admins for now.
|
||||
if !hasPermission(userPerms, PERM_ADMIN) {
|
||||
@@ -160,3 +186,81 @@ func getEnabledFeatures(userPerms int) ServerFeatures {
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func saveTwitchConfig(c game.IGDB) error {
|
||||
// If existing client id and secret are same.. just return here
|
||||
if (Config.TWITCH.ClientID != nil && c.ClientID != nil && Config.TWITCH.ClientSecret != nil && c.ClientSecret != nil) &&
|
||||
*Config.TWITCH.ClientID == *c.ClientID && *Config.TWITCH.ClientSecret == *c.ClientSecret {
|
||||
slog.Info("saveTwitchConfig: New ClientID and ClientSecret match old ClientID and ClientSecret.. ignoring request to update.")
|
||||
return nil
|
||||
}
|
||||
// Update our config
|
||||
Config.TWITCH.ClientID = c.ClientID
|
||||
Config.TWITCH.ClientSecret = c.ClientSecret
|
||||
Config.TWITCH.AccessToken = ""
|
||||
Config.TWITCH.AccessTokenExpires = time.Time{}
|
||||
// Try to init again
|
||||
err := Config.TWITCH.Init()
|
||||
if err != nil {
|
||||
slog.Error("saveTwitchConfig failed to initialize TWITCH", "error", err)
|
||||
return errors.New("initialization with credentials failed")
|
||||
}
|
||||
err = writeConfig()
|
||||
if err != nil {
|
||||
slog.Error("saveTwitchConfig failed to write config", "error", err)
|
||||
return errors.New("failed to save config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ServerStats struct {
|
||||
Users int64 `json:"users"`
|
||||
PrivateUsers int64 `json:"privateUsers"`
|
||||
WatchedMovies int64 `json:"watchedMovies"`
|
||||
WatchedShows int64 `json:"watchedShows"`
|
||||
WatchedSeasons int64 `json:"watchedSeasons"`
|
||||
MostWatchedMovie Content `json:"mostWatchedMovie"`
|
||||
MostWatchedShow Content `json:"mostWatchedShow"`
|
||||
Activities int64 `json:"activities"`
|
||||
}
|
||||
|
||||
// Collect and return server stats
|
||||
// I cant sql so this the best yall gettin
|
||||
func getServerStats(db *gorm.DB) ServerStats {
|
||||
stats := ServerStats{}
|
||||
resp := db.Model(&User{}).Count(&stats.Users).Where("private = 1").Count(&stats.PrivateUsers)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - Users query failed", "error", resp.Error)
|
||||
}
|
||||
resp = db.Model(&WatchedSeason{}).Count(&stats.WatchedSeasons)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - WatchedSeasons query failed", "error", resp.Error)
|
||||
}
|
||||
resp = db.Model(&Activity{}).Count(&stats.Activities)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - Activities query failed", "error", resp.Error)
|
||||
}
|
||||
resp = db.Joins("JOIN contents ON contents.id = watcheds.content_id AND contents.type = ?", "tv").Find(&Watched{}).Count(&stats.WatchedShows)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - WatchedShows query failed", "error", resp.Error)
|
||||
}
|
||||
resp = db.Joins("JOIN contents ON contents.id = watcheds.content_id AND contents.type = ?", "movie").Find(&Watched{}).Count(&stats.WatchedMovies)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - WatchedMovies query failed", "error", resp.Error)
|
||||
}
|
||||
|
||||
var w Watched
|
||||
resp = db.Model(&Watched{}).Select("content_id, COUNT(*) AS mag").Joins("JOIN contents ON contents.type = ? AND contents.id = watcheds.content_id", "tv").Group("content_id").Order("mag DESC").Preload("Content").First(&w)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - MostWatchedShow query failed", "error", resp.Error)
|
||||
} else {
|
||||
stats.MostWatchedShow = *w.Content
|
||||
}
|
||||
resp = db.Model(&Watched{}).Select("content_id, COUNT(*) AS mag").Joins("JOIN contents ON contents.type = ? AND contents.id = watcheds.content_id", "movie").Group("content_id").Order("mag DESC").Preload("Content").First(&w)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - MostWatchedMovie query failed", "error", resp.Error)
|
||||
} else {
|
||||
stats.MostWatchedMovie = *w.Content
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ package main
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ContentType string
|
||||
@@ -34,6 +38,138 @@ type Content struct {
|
||||
NumberOfSeasons uint32 `json:"numberOfSeasons"`
|
||||
}
|
||||
|
||||
// onlyUpdate - If we should only update existing row if exists, or false to create/update if not exist.
|
||||
func saveContent(db *gorm.DB, c *Content, onlyUpdate bool) error {
|
||||
slog.Info("Saving content to db", "id", c.TmdbID, "title", c.Title)
|
||||
if c.TmdbID == 0 || c.Title == "" || c.Type == "" {
|
||||
slog.Error("saveContent: content missing id, title or type!", "id", c.TmdbID, "title", c.Title, "type", c.Type)
|
||||
return errors.New("content missing id or title")
|
||||
}
|
||||
var res *gorm.DB
|
||||
if onlyUpdate {
|
||||
// We only want to update an existing row, if it exists.
|
||||
res = db.Model(&Content{}).Where("type = ? AND tmdb_id = ?", c.Type, c.TmdbID).Updates(c)
|
||||
if res.Error != nil {
|
||||
slog.Error("saveContent: Error updating content in database", "error", res.Error.Error())
|
||||
return errors.New("failed to update cached content in database")
|
||||
}
|
||||
} else {
|
||||
// On conflict, update existing row with details incase any were updated/missing.
|
||||
res = db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "tmdb_id"}, {Name: "type"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"title",
|
||||
"poster_path",
|
||||
"overview",
|
||||
"release_date",
|
||||
"popularity",
|
||||
"vote_average",
|
||||
"vote_count",
|
||||
"imdb_id",
|
||||
"status",
|
||||
"budget",
|
||||
"revenue",
|
||||
"runtime",
|
||||
"number_of_episodes",
|
||||
"number_of_seasons",
|
||||
}),
|
||||
}).Create(&c)
|
||||
if res.Error != nil {
|
||||
// Error if anything but unique contraint error
|
||||
if res.Error != gorm.ErrDuplicatedKey {
|
||||
slog.Error("saveContent: Error creating content in database", "error", res.Error.Error())
|
||||
return errors.New("failed to cache content in database")
|
||||
}
|
||||
}
|
||||
}
|
||||
// If row created, download the image
|
||||
if res.RowsAffected > 0 {
|
||||
slog.Debug("saveContent: Downloading poster.")
|
||||
err := download("https://image.tmdb.org/t/p/w500"+c.PosterPath, path.Join("./data/img", c.PosterPath))
|
||||
if err != nil {
|
||||
slog.Error("saveContent: Failed to download content image!", "error", err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cacheContentTv(db *gorm.DB, content TMDBShowDetails, onlyUpdate bool) (Content, error) {
|
||||
slog.Debug("cacheContentTv", "content", content)
|
||||
var (
|
||||
releaseDate time.Time
|
||||
runtime uint32
|
||||
)
|
||||
var dateFormat = "2006-01-02"
|
||||
releaseDate, err := time.Parse(dateFormat, content.FirstAirDate)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse tv release date", "error", err)
|
||||
}
|
||||
if len(content.EpisodeRunTime) > 0 {
|
||||
runtime = uint32(content.EpisodeRunTime[0])
|
||||
}
|
||||
|
||||
c := Content{
|
||||
TmdbID: content.ID,
|
||||
Title: content.Name,
|
||||
Overview: content.Overview,
|
||||
PosterPath: content.PosterPath,
|
||||
Type: SHOW,
|
||||
ReleaseDate: &releaseDate,
|
||||
Popularity: content.Popularity,
|
||||
VoteAverage: content.VoteAverage,
|
||||
VoteCount: content.VoteCount,
|
||||
Status: content.Status,
|
||||
Runtime: runtime,
|
||||
NumberOfEpisodes: content.NumberOfEpisodes,
|
||||
NumberOfSeasons: content.NumberOfSeasons,
|
||||
}
|
||||
|
||||
err = saveContent(db, &c, onlyUpdate)
|
||||
if err != nil {
|
||||
slog.Error("cacheContentTv: Failed to save content!", "error", err)
|
||||
return Content{}, errors.New("failed to save content")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func cacheContentMovie(db *gorm.DB, content TMDBMovieDetails, onlyUpdate bool) (Content, error) {
|
||||
var (
|
||||
releaseDate time.Time
|
||||
)
|
||||
var dateFormat = "2006-01-02"
|
||||
// Get details from movie/show response and fill out needed vars
|
||||
releaseDate, err := time.Parse(dateFormat, content.ReleaseDate)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse movie release date", "error", err)
|
||||
}
|
||||
|
||||
c := Content{
|
||||
TmdbID: content.ID,
|
||||
Title: content.Title,
|
||||
Overview: content.Overview,
|
||||
PosterPath: content.PosterPath,
|
||||
Type: MOVIE,
|
||||
ReleaseDate: &releaseDate,
|
||||
Popularity: content.Popularity,
|
||||
VoteAverage: content.VoteAverage,
|
||||
VoteCount: content.VoteCount,
|
||||
ImdbID: content.ImdbID,
|
||||
Status: content.Status,
|
||||
Budget: content.Budget,
|
||||
Revenue: content.Revenue,
|
||||
Runtime: content.Runtime,
|
||||
}
|
||||
|
||||
err = saveContent(db, &c, onlyUpdate)
|
||||
if err != nil {
|
||||
slog.Error("cacheContentMovie: Failed to save content!", "error", err)
|
||||
return Content{}, errors.New("failed to save content")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Getting only region needed from api is not a feature yet
|
||||
// https://trello.com/c/75tR4cpF/106-add-watch-provider-region-filtering
|
||||
// When it is, this can be removed for that instead.
|
||||
@@ -68,7 +204,7 @@ func searchContent(query string) (TMDBSearchMultiResponse, error) {
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func movieDetails(id string, country string, rParams map[string]string) (TMDBMovieDetails, error) {
|
||||
func movieDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBMovieDetails, error) {
|
||||
resp := new(TMDBMovieDetails)
|
||||
err := tmdbRequest("/movie/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
@@ -76,6 +212,7 @@ func movieDetails(id string, country string, rParams map[string]string) (TMDBMov
|
||||
return TMDBMovieDetails{}, errors.New("failed to complete movie details request")
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
go cacheContentMovie(db, *resp, true)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -89,7 +226,7 @@ func movieCredits(id string) (TMDBContentCredits, error) {
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func tvDetails(id string, country string, rParams map[string]string) (TMDBShowDetails, error) {
|
||||
func tvDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBShowDetails, error) {
|
||||
resp := new(TMDBShowDetails)
|
||||
err := tmdbRequest("/tv/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
@@ -97,6 +234,7 @@ func tvDetails(id string, country string, rParams map[string]string) (TMDBShowDe
|
||||
return TMDBShowDetails{}, errors.New("failed to complete tv details request")
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
go cacheContentTv(db, *resp, true)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -104,7 +242,7 @@ func tvCredits(id string) (TMDBContentCredits, error) {
|
||||
resp := new(TMDBContentCredits)
|
||||
err := tmdbRequest("/tv/"+id+"/credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete tv cast request!", err.Error())
|
||||
slog.Error("Failed to complete tv cast request!", "error", err.Error())
|
||||
return TMDBContentCredits{}, errors.New("failed to complete tv cast request")
|
||||
}
|
||||
return *resp, nil
|
||||
@@ -142,7 +280,7 @@ func personCredits(id string) (TMDBPersonCombinedCredits, error) {
|
||||
|
||||
func discoverMovies() (TMDBDiscoverMovies, error) {
|
||||
resp := new(TMDBDiscoverMovies)
|
||||
err := tmdbRequest("/discover/movie/", map[string]string{"page": "1"}, &resp)
|
||||
err := tmdbRequest("/discover/movie", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover movies request!", "error", err.Error())
|
||||
return TMDBDiscoverMovies{}, errors.New("failed to complete discover movies request")
|
||||
@@ -152,7 +290,7 @@ func discoverMovies() (TMDBDiscoverMovies, error) {
|
||||
|
||||
func discoverTv() (TMDBDiscoverShows, error) {
|
||||
resp := new(TMDBDiscoverShows)
|
||||
err := tmdbRequest("/discover/tv/", map[string]string{"page": "1"}, &resp)
|
||||
err := tmdbRequest("/discover/tv", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover tv request!", "error", err.Error())
|
||||
return TMDBDiscoverShows{}, errors.New("failed to complete discover tv request")
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Database struct, only internal.
|
||||
type Follow struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
UserID uint `gorm:"primaryKey:usr_id_to_followed_id;not null;check:user_id != followed_user_id" json:"-"`
|
||||
User User `json:"-"`
|
||||
FollowedUserID uint `gorm:"primaryKey:usr_id_to_followed_id;not null" json:"-"`
|
||||
FollowedUser User `json:"-"`
|
||||
}
|
||||
|
||||
// For end users to see.
|
||||
type FollowPublic struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
FollowedUser PublicUser `json:"followedUser"`
|
||||
}
|
||||
|
||||
type FollowThoughts struct {
|
||||
FollowedUser PublicUser `json:"followedUser"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
}
|
||||
|
||||
func followUser(db *gorm.DB, currentUserId uint, toFollowUserId uint) (FollowPublic, error) {
|
||||
f := Follow{UserID: currentUserId, FollowedUserID: toFollowUserId}
|
||||
res := db.Model(&Follow{}).Create(&f)
|
||||
if res.Error != nil {
|
||||
slog.Error("followUser: Error on inserting follow.", "error", res.Error)
|
||||
err := "failed to insert follow"
|
||||
if res.Error == gorm.ErrDuplicatedKey {
|
||||
err = "already followed"
|
||||
}
|
||||
return FollowPublic{}, errors.New(err)
|
||||
}
|
||||
// Now get the row with preloaded followed user
|
||||
var nf Follow
|
||||
res = db.Where("user_id = ? AND followed_user_id = ?", currentUserId, toFollowUserId).Preload("FollowedUser", "private = ?", 0).Take(&nf)
|
||||
if res.Error != nil {
|
||||
slog.Error("followUser: Couldn't fetch newly followed user.", "error", res.Error)
|
||||
return FollowPublic{}, errors.New("followed, but failed to fetch followed user")
|
||||
}
|
||||
return FollowPublic{CreatedAt: nf.CreatedAt, FollowedUser: nf.FollowedUser.GetSafe()}, nil
|
||||
}
|
||||
|
||||
func unfollowUser(db *gorm.DB, currentUserId uint, toFollowUserId uint) (bool, error) {
|
||||
f := Follow{UserID: currentUserId, FollowedUserID: toFollowUserId}
|
||||
res := db.Delete(&f)
|
||||
if res.Error != nil {
|
||||
slog.Error("unfollowUser: Error deleting follow.", "error", res.Error)
|
||||
err := "failed to remove follow"
|
||||
if res.Error == gorm.ErrRecordNotFound {
|
||||
err = "not following"
|
||||
}
|
||||
return false, errors.New(err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Get current users follows
|
||||
func getFollows(db *gorm.DB, userId uint) ([]FollowPublic, error) {
|
||||
var follows []Follow
|
||||
res := db.Where("user_id = ?", userId).Preload("FollowedUser", "private = ?", 0).Find(&follows)
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding follows.", "error", res.Error)
|
||||
return []FollowPublic{}, errors.New("failed to find follows")
|
||||
}
|
||||
fpub := []FollowPublic{}
|
||||
for _, v := range follows {
|
||||
// Skip followed users without an ID..
|
||||
// this will be because they have made
|
||||
// their account private after we followed them.
|
||||
if v.FollowedUser.ID == 0 {
|
||||
continue
|
||||
}
|
||||
fpub = append(fpub, FollowPublic{CreatedAt: v.CreatedAt, FollowedUser: v.FollowedUser.GetSafe()})
|
||||
}
|
||||
return fpub, nil
|
||||
}
|
||||
|
||||
// Get followed profile thoughts, rating, etc on specific content.
|
||||
func getFollowsThoughts(db *gorm.DB, userId uint, mediaType string, mediaId string) ([]FollowThoughts, error) {
|
||||
var follows []Follow
|
||||
res := db.Where("user_id = ?", userId).Preload("FollowedUser", "private = ? AND private_thoughts = ?", 0, 0).Find(&follows)
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding follows.", "error", res.Error)
|
||||
return []FollowThoughts{}, errors.New("failed to find follows")
|
||||
}
|
||||
slog.Info("getFollowsThoughts")
|
||||
var followIds []uint
|
||||
for _, v := range follows {
|
||||
// Skip empty followedUsers.. they are private.
|
||||
if v.FollowedUser.ID == 0 {
|
||||
continue
|
||||
}
|
||||
followIds = append(followIds, v.FollowedUser.ID)
|
||||
}
|
||||
var contentOrGameId int
|
||||
if mediaType == "game" {
|
||||
// Get our content id from type and tmdbId
|
||||
var content Game
|
||||
res = db.Where("igdb_id = ?", mediaId).Select("id").Find(&content)
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding content from db.", "error", res.Error)
|
||||
return []FollowThoughts{}, errors.New("failed to find content")
|
||||
}
|
||||
contentOrGameId = content.ID
|
||||
} else if mediaType == "movie" || mediaType == "tv" {
|
||||
// Get our content id from type and tmdbId
|
||||
var content Content
|
||||
res = db.Where("type = ? AND tmdb_id = ?", mediaType, mediaId).Select("id").Find(&content)
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding content from db.", "error", res.Error)
|
||||
return []FollowThoughts{}, errors.New("failed to find content")
|
||||
}
|
||||
contentOrGameId = content.ID
|
||||
} else {
|
||||
slog.Error("getFollows: Unrecognized media type (movie, tv or game supported).", "media_type", mediaType)
|
||||
return []FollowThoughts{}, errors.New("unrecognized media type")
|
||||
}
|
||||
// Get list of followeds watcheds for this content
|
||||
var fw []Watched
|
||||
if mediaType == "game" {
|
||||
res = db.Where("game_id = ? AND user_id IN ?", contentOrGameId, followIds).Find(&fw)
|
||||
} else {
|
||||
res = db.Where("content_id = ? AND user_id IN ?", contentOrGameId, followIds).Find(&fw)
|
||||
}
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding followed watcheds from db.", "error", res.Error)
|
||||
return []FollowThoughts{}, errors.New("failed to find followed watcheds")
|
||||
}
|
||||
// Create followThoughts array by combining follows and fw(atcheds)
|
||||
ft := []FollowThoughts{}
|
||||
for _, v := range fw {
|
||||
var fu PublicUser
|
||||
for _, f := range follows {
|
||||
if f.FollowedUser.ID == v.UserID {
|
||||
fu = f.FollowedUser.GetSafe()
|
||||
break
|
||||
}
|
||||
}
|
||||
// If we didn't find a related followedUser.. skip this watched entry
|
||||
if fu.ID == 0 {
|
||||
continue
|
||||
}
|
||||
ft = append(ft, FollowThoughts{FollowedUser: fu, Thoughts: v.Thoughts, Status: v.Status, Rating: v.Rating})
|
||||
}
|
||||
return ft, nil
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
igdbHost = "https://api.igdb.com/v4"
|
||||
tokenGrantType = "client_credentials"
|
||||
)
|
||||
|
||||
var tokenRefreshJobCancel context.CancelFunc
|
||||
|
||||
type IGDB struct {
|
||||
ClientID *string `json:"clientId,omitempty"`
|
||||
ClientSecret *string `json:"clientSecret,omitempty"`
|
||||
AccessToken string `json:"accessToken,omitempty"`
|
||||
AccessTokenExpires time.Time `json:"accessTokenExpires,omitempty"`
|
||||
onTokenRefreshed *func()
|
||||
}
|
||||
|
||||
func (i *IGDB) req(host string, ep string, p map[string]string, b string, resp interface{}) error {
|
||||
// if using igdb host and we have no access token, error before running req
|
||||
if host == igdbHost && (i.ClientID == nil || i.AccessToken == "") {
|
||||
return errors.New("using igdbHost without a clientID or accessToken")
|
||||
}
|
||||
|
||||
base, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return errors.New("failed to parse api uri")
|
||||
}
|
||||
|
||||
// Path params
|
||||
base.Path += ep
|
||||
|
||||
// Query params
|
||||
params := url.Values{}
|
||||
for k, v := range p {
|
||||
params.Add(k, v)
|
||||
}
|
||||
|
||||
// Add params to url
|
||||
base.RawQuery = params.Encode()
|
||||
|
||||
slog.Info("req", "base", base.String())
|
||||
|
||||
req, err := http.NewRequest("POST", base.String(), bytes.NewBuffer([]byte(b)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add igdb auth headers
|
||||
if host == igdbHost {
|
||||
req.Header.Add("Client-ID", *i.ClientID)
|
||||
req.Header.Add("Authorization", "Bearer "+i.AccessToken)
|
||||
}
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !(res.StatusCode >= 200 && res.StatusCode <= 299) {
|
||||
slog.Error("game non 2xx status code:", "status_code", res.StatusCode)
|
||||
return errors.New(string(body))
|
||||
}
|
||||
err = json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *IGDB) getNewAccessToken() (TwitchTokenResponse, error) {
|
||||
var resp TwitchTokenResponse
|
||||
err := i.req(
|
||||
"https://id.twitch.tv/oauth2",
|
||||
"/token",
|
||||
map[string]string{"client_id": *i.ClientID, "client_secret": *i.ClientSecret, "grant_type": tokenGrantType},
|
||||
"",
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("IGDB init token request failed", "error", err)
|
||||
return TwitchTokenResponse{}, errors.New("token request failed, check client id and secret")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (i *IGDB) refreshToken(ctx context.Context) {
|
||||
var exp <-chan time.Time
|
||||
|
||||
if i.AccessToken != "" && !i.AccessTokenExpires.IsZero() && i.AccessTokenExpires.Compare(time.Now()) == 1 {
|
||||
// Stored token not expired.. set exp time to token exp time - 1h.
|
||||
exp = time.After(i.AccessTokenExpires.Sub(time.Now().Add(60 * time.Second)))
|
||||
} else {
|
||||
// Token expired.. exp now..
|
||||
exp = time.After(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
slog.Info("refreshToken running")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-exp:
|
||||
slog.Info("IGDB refreshToken: Token expired (or is near expiry date)")
|
||||
r, err := i.getNewAccessToken()
|
||||
if err != nil {
|
||||
slog.Error("IGDB refreshToken: Error refreshing token (retrying in 60s):", err)
|
||||
exp = time.After(60 * time.Second)
|
||||
} else {
|
||||
slog.Info("IGDB refreshToken: Token successfully refreshed")
|
||||
i.AccessToken = r.AccessToken
|
||||
i.AccessTokenExpires = time.Now().Add(time.Duration(r.ExpiresIn) * time.Second)
|
||||
exp = time.After(i.AccessTokenExpires.Sub(time.Now().Add(60 * time.Second)))
|
||||
// Call token refresh callback if set
|
||||
if i.onTokenRefreshed != nil {
|
||||
(*i.onTokenRefreshed)()
|
||||
}
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
slog.Info("refreshToken cancelled")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (i *IGDB) OnTokenRefreshed(tokenRefreshed func()) {
|
||||
i.onTokenRefreshed = &tokenRefreshed
|
||||
}
|
||||
|
||||
// Get token and stuff
|
||||
func (i *IGDB) Init() error {
|
||||
// Cancel existing refresh job if we have a cancel func for its context
|
||||
if tokenRefreshJobCancel != nil {
|
||||
slog.Debug("IGDB init: Refresh job running.. cancelling it.")
|
||||
tokenRefreshJobCancel()
|
||||
tokenRefreshJobCancel = nil
|
||||
}
|
||||
// Stop here if we have no client id or secret.
|
||||
if i.ClientID == nil || i.ClientSecret == nil {
|
||||
slog.Error("IGDB init client id and or secret not provided")
|
||||
return errors.New("client id and or secret not provided")
|
||||
}
|
||||
slog.Debug("IGDB init running.")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
tokenRefreshJobCancel = cancel
|
||||
// Get and set first token if needed
|
||||
go i.refreshToken(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *IGDB) Search(q string) (GameSearchResponse, error) {
|
||||
slog.Debug("IGDB Search called", "query", q)
|
||||
var resp GameSearchResponse
|
||||
err := i.req(
|
||||
igdbHost,
|
||||
"/games",
|
||||
map[string]string{},
|
||||
"fields name, cover.image_id, version_title, summary, first_release_date; search \""+q+"\";",
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("IGDB Search request failed!", "error", err)
|
||||
return GameSearchResponse{}, errors.New("request failed")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (i *IGDB) GameDetails(id string) (GameDetailsResponse, error) {
|
||||
slog.Debug("IGDB GameDetails called", "id", id)
|
||||
var resp []GameDetailsResponse
|
||||
err := i.req(
|
||||
igdbHost,
|
||||
"/games",
|
||||
map[string]string{},
|
||||
`fields
|
||||
name,
|
||||
cover.image_id,
|
||||
version_title,
|
||||
summary,
|
||||
storyline,
|
||||
first_release_date,
|
||||
artworks.width,
|
||||
artworks.height,
|
||||
artworks.image_id,
|
||||
category,
|
||||
platforms.name,
|
||||
game_modes.name,
|
||||
genres.id,
|
||||
genres.name,
|
||||
involved_companies.developer,
|
||||
involved_companies.publisher,
|
||||
involved_companies.porting,
|
||||
involved_companies.supporting,
|
||||
involved_companies.company.name,
|
||||
involved_companies.company.description,
|
||||
involved_companies.company.slug,
|
||||
involved_companies.company.websites.category,
|
||||
involved_companies.company.websites.trusted,
|
||||
involved_companies.company.websites.url,
|
||||
rating,
|
||||
rating_count,
|
||||
status,
|
||||
url,
|
||||
websites.trusted,
|
||||
websites.category,
|
||||
websites.url,
|
||||
videos.name,
|
||||
videos.video_id,
|
||||
similar_games.id,
|
||||
similar_games.name,
|
||||
similar_games.summary,
|
||||
similar_games.cover.image_id,
|
||||
similar_games.first_release_date;
|
||||
where id = `+id+";",
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("IGDB GameDetails request failed!", "error", err)
|
||||
return GameDetailsResponse{}, errors.New("request failed")
|
||||
}
|
||||
if len(resp) > 0 {
|
||||
return resp[0], nil
|
||||
}
|
||||
return GameDetailsResponse{}, errors.New("no game details recieved")
|
||||
}
|
||||
|
||||
// Basic game details for when we are using them only to update our cache.
|
||||
// In these cases, it's a waste to ask for everything, when we don't need it.
|
||||
func (i *IGDB) GameDetailsBasic(id string) (GameDetailsBasicResponse, error) {
|
||||
slog.Debug("IGDB GameDetails called", "id", id)
|
||||
var resp []GameDetailsBasicResponse
|
||||
err := i.req(
|
||||
igdbHost,
|
||||
"/games",
|
||||
map[string]string{},
|
||||
`fields
|
||||
name,
|
||||
cover.image_id,
|
||||
summary,
|
||||
storyline,
|
||||
first_release_date,
|
||||
category,
|
||||
platforms.name,
|
||||
game_modes.name,
|
||||
genres.name,
|
||||
rating,
|
||||
rating_count,
|
||||
status;
|
||||
where id = `+id+";",
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("IGDB GameDetails request failed!", "error", err)
|
||||
return GameDetailsBasicResponse{}, errors.New("request failed")
|
||||
}
|
||||
if len(resp) > 0 {
|
||||
return resp[0], nil
|
||||
}
|
||||
return GameDetailsBasicResponse{}, errors.New("no game details recieved")
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TwitchTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// So we can unmarshall the unix timestamps returned from igdb into time.Time.
|
||||
type UnixTime struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
func (u *UnixTime) UnmarshalJSON(b []byte) error {
|
||||
var timestamp int64
|
||||
err := json.Unmarshal(b, ×tamp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Time = time.Unix(timestamp, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only the fields we request included in each struct
|
||||
|
||||
type GameSearchResponse []struct {
|
||||
ID int `json:"id"`
|
||||
Cover struct {
|
||||
ID int `json:"id"`
|
||||
ImageID string `json:"image_id"`
|
||||
} `json:"cover"`
|
||||
FirstReleaseDate UnixTime `json:"first_release_date"`
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
VersionTitle string `json:"version_title,omitempty"`
|
||||
}
|
||||
|
||||
type GameDetailsResponse struct {
|
||||
ID int `json:"id"`
|
||||
Artworks []struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
ImageID string `json:"image_id"`
|
||||
} `json:"artworks"`
|
||||
Category int `json:"category"`
|
||||
Cover struct {
|
||||
ID int `json:"id"`
|
||||
ImageID string `json:"image_id"`
|
||||
} `json:"cover"`
|
||||
FirstReleaseDate UnixTime `json:"first_release_date"`
|
||||
GameModes []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"game_modes"`
|
||||
Genres []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"genres"`
|
||||
InvolvedCompanies []struct {
|
||||
ID int `json:"id"`
|
||||
Company struct {
|
||||
ID int `json:"id"`
|
||||
Description string `json:"description"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Websites []struct {
|
||||
ID int `json:"id"`
|
||||
Category int `json:"category"`
|
||||
Trusted bool `json:"trusted"`
|
||||
URL string `json:"url"`
|
||||
} `json:"websites"`
|
||||
} `json:"company"`
|
||||
Developer bool `json:"developer"`
|
||||
Porting bool `json:"porting"`
|
||||
Publisher bool `json:"publisher"`
|
||||
Supporting bool `json:"supporting"`
|
||||
} `json:"involved_companies"`
|
||||
Name string `json:"name"`
|
||||
Platforms []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"platforms"`
|
||||
Rating float64 `json:"rating"`
|
||||
RatingCount int `json:"rating_count"`
|
||||
Summary string `json:"summary"`
|
||||
Storyline string `json:"storyline"`
|
||||
Status int `json:"status"`
|
||||
URL string `json:"url"`
|
||||
Videos []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
VideoID string `json:"video_id"`
|
||||
} `json:"videos"`
|
||||
Websites []struct {
|
||||
ID int `json:"id"`
|
||||
Category int `json:"category"`
|
||||
Trusted bool `json:"trusted"`
|
||||
URL string `json:"url"`
|
||||
} `json:"websites"`
|
||||
SimilarGame []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary"`
|
||||
FirstReleaseDate UnixTime `json:"first_release_date"`
|
||||
Cover struct {
|
||||
ID int `json:"id"`
|
||||
ImageID string `json:"image_id"`
|
||||
} `json:"cover"`
|
||||
} `json:"similar_games"`
|
||||
}
|
||||
|
||||
type GameDetailsBasicResponse struct {
|
||||
ID int `json:"id"`
|
||||
Category int `json:"category"`
|
||||
Cover struct {
|
||||
ImageID string `json:"image_id"`
|
||||
} `json:"cover"`
|
||||
FirstReleaseDate UnixTime `json:"first_release_date"`
|
||||
GameModes []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"game_modes"`
|
||||
Genres []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"genres"`
|
||||
Name string `json:"name"`
|
||||
Platforms []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"platforms"`
|
||||
Rating float64 `json:"rating"`
|
||||
RatingCount int `json:"rating_count"`
|
||||
Summary string `json:"summary"`
|
||||
Storyline string `json:"storyline"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/game"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// For storing cached games, so we can serve the basic local data for watched list to work
|
||||
type Game struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
IgdbID int `json:"igdbId" gorm:"uniqueIndex;not null"`
|
||||
Name string `json:"name"`
|
||||
CoverID string `json:"coverId"`
|
||||
Summary string `json:"summary"`
|
||||
Storyline string `json:"storyline"`
|
||||
// First release date
|
||||
ReleaseDate *time.Time `json:"releaseDate,omitempty"`
|
||||
Rating float64 `json:"rating"`
|
||||
RatingCount int `json:"ratingCount"`
|
||||
Status int `json:"status"`
|
||||
Category int `json:"category"`
|
||||
// Arrays turned to strings that may be useful
|
||||
GameModes string `json:"gameModes"`
|
||||
Genres string `json:"genres"`
|
||||
Platforms string `json:"platforms"`
|
||||
// Id to poster image row (cached game cover)
|
||||
PosterID *uint `json:"-"`
|
||||
Poster *Image `json:"poster,omitempty"`
|
||||
}
|
||||
|
||||
type PlayedAddRequest struct {
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
IgdbID int `json:"igdbId" binding:"required"`
|
||||
}
|
||||
|
||||
// Cache(save) game to our table
|
||||
func saveGame(db *gorm.DB, c *Game, onlyUpdate bool) error {
|
||||
slog.Info("Saving game to db", "id", c.IgdbID, "name", c.Name)
|
||||
if c.IgdbID == 0 || c.Name == "" {
|
||||
slog.Error("saveGame: content missing id or name!", "id", c.IgdbID, "name", c.Name)
|
||||
return errors.New("game missing id or title")
|
||||
}
|
||||
if c.CoverID != "" {
|
||||
p, err := downloadAndInsertImage(db, "https://images.igdb.com/igdb/image/upload/t_cover_big/"+c.CoverID+".png", "games")
|
||||
if err != nil {
|
||||
slog.Error("saveGame: Failed to cache game cover.", "error", err)
|
||||
} else {
|
||||
slog.Debug("saveGame: Cached game cover", "p", p)
|
||||
c.PosterID = &p.ID
|
||||
}
|
||||
}
|
||||
var res *gorm.DB
|
||||
if onlyUpdate {
|
||||
// We only want to update an existing row, if it exists.
|
||||
res = db.Model(&Game{}).Where("igdb_id = ?", c.IgdbID).Updates(c)
|
||||
if res.Error != nil {
|
||||
slog.Error("saveGame: Error updating game in database", "error", res.Error.Error())
|
||||
return errors.New("failed to update cached game in database")
|
||||
}
|
||||
} else {
|
||||
// On conflict, update existing row with details incase any were updated/missing.
|
||||
res = db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "igdb_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"name",
|
||||
"cover_id",
|
||||
"summary",
|
||||
"storyline",
|
||||
"release_date",
|
||||
"rating",
|
||||
"rating_count",
|
||||
"status",
|
||||
"game_modes",
|
||||
"genres",
|
||||
}),
|
||||
}).Create(&c)
|
||||
if res.Error != nil {
|
||||
// Error if anything but unique contraint error
|
||||
if res.Error != gorm.ErrDuplicatedKey {
|
||||
slog.Error("saveGame: Error creating game in database", "error", res.Error.Error())
|
||||
return errors.New("failed to cache game in database")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cacheGame(db *gorm.DB, g game.GameDetailsBasicResponse, onlyUpdate bool) (Game, error) {
|
||||
slog.Debug("cacheGame", "game_details", g)
|
||||
var (
|
||||
gameModes string
|
||||
genres string
|
||||
platforms string
|
||||
)
|
||||
if len(g.GameModes) > 0 {
|
||||
for _, v := range g.GameModes {
|
||||
gameModes += v.Name + "|"
|
||||
}
|
||||
}
|
||||
if len(g.Genres) > 0 {
|
||||
for _, v := range g.Genres {
|
||||
genres += v.Name + "|"
|
||||
}
|
||||
}
|
||||
if len(g.Platforms) > 0 {
|
||||
for _, v := range g.Platforms {
|
||||
platforms += v.Name + "|"
|
||||
}
|
||||
}
|
||||
c := Game{
|
||||
IgdbID: g.ID,
|
||||
Name: g.Name,
|
||||
CoverID: g.Cover.ImageID,
|
||||
Summary: g.Summary,
|
||||
Storyline: g.Storyline,
|
||||
ReleaseDate: &g.FirstReleaseDate.Time,
|
||||
Rating: (g.Rating),
|
||||
RatingCount: g.RatingCount,
|
||||
Status: g.Status,
|
||||
Category: g.Category,
|
||||
GameModes: gameModes,
|
||||
Genres: genres,
|
||||
Platforms: platforms,
|
||||
}
|
||||
err := saveGame(db, &c, onlyUpdate)
|
||||
if err != nil {
|
||||
slog.Error("cacheGame: Failed to save game!", "error", err)
|
||||
return Game{}, errors.New("failed to save game")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// For adding/updating played games, we will reuse methods defined in watched.go where easily possible.
|
||||
|
||||
func addPlayed(db *gorm.DB, igdb *game.IGDB, userId uint, ar PlayedAddRequest, at ActivityType) (Watched, error) {
|
||||
slog.Debug("Adding played item", "userId", userId, "igdbId", ar.IgdbID)
|
||||
|
||||
var game Game
|
||||
db.Where("igdb_id = ?", ar.IgdbID).Find(&game)
|
||||
|
||||
// Create game if not found from our db
|
||||
if game == (Game{}) {
|
||||
slog.Debug("Game not in db, fetching...")
|
||||
|
||||
resp, err := igdb.GameDetailsBasic(strconv.Itoa(ar.IgdbID))
|
||||
if err != nil {
|
||||
slog.Error("addPlayed content tmdb api request failed", "error", err)
|
||||
return Watched{}, errors.New("failed to find requested games")
|
||||
}
|
||||
|
||||
game, err = cacheGame(db, resp, false)
|
||||
if err != nil {
|
||||
slog.Error("addPlayed failed to cache game", "igdb_id", ar.IgdbID, "err", err)
|
||||
return Watched{}, errors.New("failed to cache content")
|
||||
}
|
||||
}
|
||||
// Error if content has no id
|
||||
if game.ID == 0 {
|
||||
return Watched{}, errors.New("failed to find game by id")
|
||||
}
|
||||
// Create watched entry in db
|
||||
if ar.Status == "" {
|
||||
ar.Status = FINISHED
|
||||
}
|
||||
watched := Watched{Status: ar.Status, Rating: ar.Rating, UserID: userId, GameID: &game.ID}
|
||||
res := db.Create(&watched)
|
||||
if res.Error != nil {
|
||||
if res.Error == gorm.ErrDuplicatedKey {
|
||||
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 {
|
||||
slog.Info("addPlayed: Watched list item for this content 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 {
|
||||
slog.Error("addPlayed: Failed to restore soft deleted watch list item", "error", res.Error)
|
||||
return Watched{}, errors.New("content already on watched list. errored removing soft delete timestamp")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Error("Error adding watched content to database", "error", res.Error.Error())
|
||||
return Watched{}, errors.New("failed adding content to database")
|
||||
}
|
||||
}
|
||||
slog.Debug("Added watched list item", "item", watched)
|
||||
|
||||
var activity Activity
|
||||
activityJson, err := json.Marshal(map[string]interface{}{"status": ar.Status, "rating": ar.Rating})
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal json for data in ADD_WATCHED activity request, adding without data", "error", err.Error())
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at})
|
||||
} else {
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at, Data: string(activityJson)})
|
||||
}
|
||||
watched.Activity = append(watched.Activity, activity)
|
||||
watched.Game = &game
|
||||
return watched, nil
|
||||
}
|
||||
@@ -3,48 +3,50 @@ module github.com/sbondCo/Watcharr
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/buckket/go-blurhash v1.1.0
|
||||
github.com/gin-contrib/cache v1.2.0
|
||||
github.com/gin-contrib/cors v1.4.0
|
||||
github.com/gin-contrib/cors v1.7.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.1.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.15.0
|
||||
golang.org/x/crypto v0.21.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/sqlite v1.5.4
|
||||
gorm.io/gorm v1.25.5
|
||||
gorm.io/driver/sqlite v1.5.5
|
||||
gorm.io/gorm v1.25.7
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
|
||||
github.com/bytedance/sonic v1.10.2 // indirect
|
||||
github.com/bytedance/sonic v1.11.2 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.0 // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.15.5 // indirect
|
||||
github.com/go-playground/validator/v10 v10.19.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/gomodule/redigo v1.8.9 // indirect
|
||||
github.com/google/go-cmp v0.5.9 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
||||
github.com/memcachier/mc/v3 v3.0.3 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
golang.org/x/arch v0.5.0 // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
golang.org/x/sys v0.14.0 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.7.0 // indirect
|
||||
golang.org/x/net v0.22.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
||||
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
|
||||
github.com/buckket/go-blurhash v1.1.0/go.mod h1:aT2iqo5W9vu9GpyoLErKfTHwgODsZp3bQfXjXJUxNb8=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE=
|
||||
github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
|
||||
github.com/bytedance/sonic v1.11.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
|
||||
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -18,34 +21,26 @@ github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uq
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cache v1.2.0 h1:WA+AJR4kmHDTaLLShCHo/IeWVmmGRZ3Lsr3JQ46tFlE=
|
||||
github.com/gin-contrib/cache v1.2.0/go.mod h1:2KkFL8PSnPF3Tt5E2Jpc3HWuBAUKqGZnClCFMm0tXQI=
|
||||
github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
|
||||
github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs=
|
||||
github.com/gin-contrib/cors v1.7.0 h1:wZX2wuZ0o7rV2/1i7gb4Jn+gW7HBqaP91fizJkBUJOA=
|
||||
github.com/gin-contrib/cors v1.7.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
|
||||
github.com/go-playground/validator/v10 v10.15.5 h1:LEBecTWb/1j5TNY1YYG2RcOUN3R7NLylN+x8TTueE24=
|
||||
github.com/go-playground/validator/v10 v10.15.5/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
|
||||
github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.1.0 h1:UGKbA/IPjtS6zLcdB7i5TyACMgSbOTiR8qzXgw8HWQU=
|
||||
github.com/golang-jwt/jwt/v5 v5.1.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
|
||||
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -58,23 +53,17 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
|
||||
@@ -84,78 +73,54 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62/go.mod h1:65XQgovT59RWatovFwnwocoUxiI/eENTnOY5GK3STuY=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
|
||||
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
|
||||
golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
|
||||
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
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=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
|
||||
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
|
||||
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
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=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
|
||||
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
|
||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
|
||||
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
|
||||
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/buckket/go-blurhash"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// For user uploaded images
|
||||
type Image struct {
|
||||
ID uint `gorm:"primarykey" json:"-"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Hash string `gorm:"uniqueIndex;not null" json:"-"`
|
||||
BlurHash string `json:"blurHash"`
|
||||
// Path constructable from hash alone, but I can't decide
|
||||
// if I should have this or not so I figure it's easier
|
||||
// to remove it later than to add it later....... -_-
|
||||
Path string `gorm:"not null" json:"path"`
|
||||
}
|
||||
|
||||
// Insert an image into database
|
||||
func insertImage(db *gorm.DB, hash string, path string, f io.Reader) (Image, error) {
|
||||
bh, _ := getBlurHash(f)
|
||||
img := Image{
|
||||
Hash: hash,
|
||||
Path: path,
|
||||
BlurHash: bh,
|
||||
}
|
||||
r := db.Where(Image{Hash: hash}).FirstOrCreate(&img)
|
||||
if r.Error != nil {
|
||||
slog.Error("insertImage firstOrCreate failed!", "error", r.Error)
|
||||
return Image{}, errors.New("failed to select or create image")
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func getBlurHash(img io.Reader) (string, error) {
|
||||
i, _, err := image.Decode(img)
|
||||
if err != nil {
|
||||
// Handle errors
|
||||
slog.Error("getBlurHash decoding image failed", "error", err)
|
||||
return "", errors.New("decoding image failed")
|
||||
}
|
||||
bh, _ := blurhash.Encode(6, 5, i)
|
||||
if err != nil {
|
||||
// Handle errors
|
||||
slog.Error("getBlurHash generation failed", "error", err)
|
||||
return "", errors.New("blur hash generation failed")
|
||||
}
|
||||
slog.Debug("getBlurHash", "hash", bh)
|
||||
return bh, nil
|
||||
}
|
||||
|
||||
func cleanupImages(db *gorm.DB) {
|
||||
slog.Info("cleanupImages running")
|
||||
var unusedImgs []Image
|
||||
// Select images that are not referenced by at least one other row.
|
||||
// Currently only used for user avatars, add new tables when used.
|
||||
db.Raw(`SELECT *
|
||||
FROM images
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM users
|
||||
WHERE users.avatar_id = images.id
|
||||
);`).Scan(&unusedImgs)
|
||||
slog.Info("cleanupImages: scanned for unused images", "amount", len(unusedImgs))
|
||||
if len(unusedImgs) > 0 {
|
||||
for _, v := range unusedImgs {
|
||||
slog.Debug("cleanupImages: removing an image", "id", v.ID, "path", v.Path)
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
// Try to delete image from db
|
||||
if err := tx.Where("id = ?", v.ID).Delete(&Image{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// hope its ok to do this sorta thing here :skull:
|
||||
if err := os.Remove(path.Join(DataPath, v.Path)); err != nil {
|
||||
return err
|
||||
}
|
||||
// commit transaction if no errors
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("cleanupImages: failed to remove image - db row and file kept", "img", v, "error", err)
|
||||
} else {
|
||||
slog.Debug("cleanupImages: successfully removed unused image.", "id", v.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isValidImageType(f multipart.File) error {
|
||||
// Read first 512 bytes, since that is all `DetectContentType` will evaluate on.
|
||||
// Reading whole file is a waste.
|
||||
buff := make([]byte, 512)
|
||||
if _, err := f.Read(buff); err != nil {
|
||||
slog.Error("isValidImageType: failed to read file into buffer", "error", err)
|
||||
return errors.New("failed to verify if image is valid")
|
||||
}
|
||||
t := http.DetectContentType(buff)
|
||||
slog.Debug("isValidImageType", "type", t)
|
||||
if t != "image/png" && t != "image/jpeg" && t != "image/webp" && t != "image/gif" {
|
||||
slog.Debug("isValidImageType: rejecting file as not valid (supported) image type")
|
||||
return errors.New("invalid file type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadAndInsertImage(db *gorm.DB, url string, imgSubPath string) (Image, error) {
|
||||
slog.Debug("Attempting to download image", "url", url)
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return Image{}, fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Read body into byte array, then create new reader
|
||||
// So we have the ability to seek.
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage failed to read response into byte array", "error", err)
|
||||
return Image{}, err
|
||||
}
|
||||
br := bytes.NewReader(b)
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, br); err != nil {
|
||||
log.Fatal(err) // TODO nu le fatal
|
||||
}
|
||||
hs := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
// Seek back for file
|
||||
_, err = br.Seek(0, 0)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage seeking back to start of br failed", "error", err)
|
||||
return Image{}, err
|
||||
}
|
||||
|
||||
outp := path.Join("img/", imgSubPath, hs[0:1], hs+filepath.Ext(resp.Request.URL.Path))
|
||||
dataOutP := path.Join(DataPath, outp)
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(dataOutP)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
err = os.MkdirAll(path.Dir(dataOutP), 0764)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
}
|
||||
// If dirs made, try making file again
|
||||
out, err = os.Create(dataOutP)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
}
|
||||
} else {
|
||||
return Image{}, err
|
||||
}
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, br)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
}
|
||||
|
||||
// Seek back for insertImage
|
||||
_, err = br.Seek(0, 0)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage seeking back to start of br failed", "error", err)
|
||||
return Image{}, err
|
||||
}
|
||||
|
||||
img, err := insertImage(db, hs, outp, br)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -25,9 +27,14 @@ var (
|
||||
)
|
||||
|
||||
type ImportRequest struct {
|
||||
Name string `json:"name"`
|
||||
TmdbID int `json:"tmdbId"`
|
||||
Type ContentType `json:"type"`
|
||||
Name string `json:"name"`
|
||||
TmdbID int `json:"tmdbId"`
|
||||
Type ContentType `json:"type"`
|
||||
Rating int8 `json:"rating"`
|
||||
RatingCustomDate *time.Time `json:"ratingCustomDate"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
DatesWatched []time.Time `json:"datesWatched"`
|
||||
}
|
||||
|
||||
type ImportResponse struct {
|
||||
@@ -45,19 +52,19 @@ func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse,
|
||||
if ar.TmdbID != 0 && (ar.Type == MOVIE || ar.Type == SHOW) {
|
||||
tid := strconv.Itoa(ar.TmdbID)
|
||||
if ar.Type == MOVIE {
|
||||
cr, err := movieDetails(tid, "", map[string]string{})
|
||||
cr, err := movieDetails(db, tid, "", map[string]string{})
|
||||
if err != nil {
|
||||
return ImportResponse{}, errors.New("movie details request failed")
|
||||
}
|
||||
slog.Debug("import: by tmdbid of movie", "cr", cr)
|
||||
return successfulImport(db, userId, cr.ID, MOVIE)
|
||||
return successfulImport(db, userId, cr.ID, MOVIE, ar)
|
||||
} else if ar.Type == SHOW {
|
||||
cr, err := tvDetails(tid, "", map[string]string{})
|
||||
cr, err := tvDetails(db, tid, "", map[string]string{})
|
||||
if err != nil {
|
||||
return ImportResponse{}, errors.New("tv details request failed")
|
||||
}
|
||||
slog.Debug("import: by tmdbid of tv", "cr", cr)
|
||||
return successfulImport(db, userId, cr.ID, SHOW)
|
||||
return successfulImport(db, userId, cr.ID, SHOW, ar)
|
||||
}
|
||||
}
|
||||
sr, err := searchContent(ar.Name)
|
||||
@@ -101,20 +108,36 @@ func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse,
|
||||
// If one perfect match found, import it
|
||||
if perfectMatch.ID != 0 {
|
||||
slog.Debug("import: importing from perfect match")
|
||||
return successfulImport(db, userId, perfectMatch.ID, ContentType(perfectMatch.MediaType))
|
||||
return successfulImport(db, userId, perfectMatch.ID, ContentType(perfectMatch.MediaType), ar)
|
||||
}
|
||||
return ImportResponse{Type: IMPORT_MULTI, Results: pMatches}, nil
|
||||
} else {
|
||||
slog.Debug("import: success.. only found one result")
|
||||
return successfulImport(db, userId, pMatches[0].ID, ContentType(pMatches[0].MediaType))
|
||||
return successfulImport(db, userId, pMatches[0].ID, ContentType(pMatches[0].MediaType), ar)
|
||||
}
|
||||
}
|
||||
|
||||
func successfulImport(db *gorm.DB, userId uint, contentId int, contentType ContentType) (ImportResponse, error) {
|
||||
func successfulImport(db *gorm.DB, userId uint, contentId int, contentType ContentType, ar ImportRequest) (ImportResponse, error) {
|
||||
status := FINISHED
|
||||
if ar.Status != "" {
|
||||
status = ar.Status
|
||||
}
|
||||
// Get the latest date from DatesWatched if we have any.
|
||||
var wDate time.Time
|
||||
if len(ar.DatesWatched) > 0 {
|
||||
for _, dw := range ar.DatesWatched {
|
||||
if dw.After(wDate) {
|
||||
wDate = dw
|
||||
}
|
||||
}
|
||||
}
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
Status: status,
|
||||
ContentID: contentId,
|
||||
ContentType: contentType,
|
||||
Rating: ar.Rating,
|
||||
Thoughts: ar.Thoughts,
|
||||
WatchedDate: wDate,
|
||||
}, IMPORTED_WATCHED)
|
||||
if err != nil {
|
||||
if err.Error() == "content already on watched list" {
|
||||
@@ -124,5 +147,28 @@ func successfulImport(db *gorm.DB, userId uint, contentId int, contentType Conte
|
||||
slog.Error("successfulImport: Failed to add content as watched", "error", err)
|
||||
return ImportResponse{Type: IMPORT_FAILED}, nil
|
||||
}
|
||||
// Add activity of the original time the show was added to the users watchlist on whichever platform they are coming from.
|
||||
if ar.RatingCustomDate != nil {
|
||||
var addedActivity Activity
|
||||
if len(w.Activity) > 0 {
|
||||
activityJson, _ := json.Marshal(map[string]interface{}{"rating": ar.Rating, "linkedActivity": w.Activity[0].ID})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_RATING, Data: string(activityJson), CustomDate: ar.RatingCustomDate})
|
||||
} else {
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_RATING, Data: strconv.Itoa(int(ar.Rating)), CustomDate: ar.RatingCustomDate})
|
||||
}
|
||||
w.Activity = append(w.Activity, addedActivity)
|
||||
}
|
||||
// Add all dates watched as activity, if any
|
||||
if len(ar.DatesWatched) > 0 {
|
||||
for _, v := range ar.DatesWatched {
|
||||
customDate := v
|
||||
addedActivity, err := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_ADDED_WATCHED, CustomDate: &customDate})
|
||||
if err == nil {
|
||||
w.Activity = append(w.Activity, addedActivity)
|
||||
} else {
|
||||
slog.Error("successfulImport: Failed to add dateswatched activity.", "date", v, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ImportResponse{Type: IMPORT_SUCCESS, WatchedEntry: w}, nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type JellyfinItemSearchResponse struct {
|
||||
@@ -15,12 +18,27 @@ type JellyfinItemSearchResponse struct {
|
||||
}
|
||||
|
||||
type JellyfinItems struct {
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
ServerID string `json:"ServerId"`
|
||||
Id string `json:"Id"`
|
||||
ProviderIds struct {
|
||||
Tmdb string `json:"Tmdb"`
|
||||
} `json:"ProviderIds"`
|
||||
UserData struct {
|
||||
Rating float64 `json:"Rating"`
|
||||
PlayedPercentage float64 `json:"PlayedPercentage"`
|
||||
UnplayedItemCount int64 `json:"UnplayedItemCount"`
|
||||
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
|
||||
PlayCount int64 `json:"PlayCount"`
|
||||
IsFavorite bool `json:"IsFavorite"`
|
||||
Likes bool `json:"Likes"`
|
||||
LastPlayedDate time.Time `json:"LastPlayedDate"`
|
||||
Played bool `json:"Played"`
|
||||
Key string `json:"Key"`
|
||||
ItemId string `json:"ItemId"`
|
||||
} `json:"UserData"`
|
||||
RecursiveItemCount int64 `json:"RecursiveItemCount"`
|
||||
}
|
||||
|
||||
type JFContentFindResponse struct {
|
||||
@@ -28,6 +46,33 @@ type JFContentFindResponse struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
// Jellyfin access middleware, ensures user is a jellyfin user.
|
||||
// To be ran after AuthRequired middleware with extra data.
|
||||
func JellyfinAccessRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
slog.Debug("JellyfinAccessRequired middleware hit", "user_id", userId)
|
||||
userType := c.MustGet("userType").(UserType)
|
||||
userThirdPartyId := c.MustGet("userThirdPartyId").(string)
|
||||
userThirdPartyAuth := c.MustGet("userThirdPartyAuth").(string)
|
||||
if Config.JELLYFIN_HOST == "" {
|
||||
slog.Error("JellyfinAccessRequired: Request made to login via Jellyfin, but JELLYFIN_HOST has not been configured.")
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
if userType != JELLYFIN_USER || userThirdPartyId == "" {
|
||||
slog.Error("JellyfinAccessRequired: User is not a jellyfin user..", "user_type", userType, "user_third_party_id", userThirdPartyId)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
if userThirdPartyAuth == "" {
|
||||
slog.Error("JellyfinAccessRequired: User has no thirdPartyAuth token..")
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func jellyfinAPIRequest(method string, ep string, p map[string]string, username string, userToken string, resp interface{}) error {
|
||||
if Config.JELLYFIN_HOST == "" {
|
||||
slog.Error("jellyfinAPIRequest: JELLYFIN_HOST not configured.")
|
||||
@@ -97,18 +142,6 @@ func jellyfinContentFind(
|
||||
contentName string,
|
||||
contentTmdbId string,
|
||||
) (JFContentFindResponse, error) {
|
||||
if Config.JELLYFIN_HOST == "" {
|
||||
slog.Error("Request made to login via Jellyfin, but JELLYFIN_HOST has not been configured.")
|
||||
return JFContentFindResponse{}, errors.New("jellyfin login not enabled")
|
||||
}
|
||||
if userType != JELLYFIN_USER || userThirdPartyId == "" {
|
||||
slog.Error("User is not a jellyfin user..", "user_type", userType, "user_third_party_id", userThirdPartyId)
|
||||
return JFContentFindResponse{}, errors.New("not jellyfin user")
|
||||
}
|
||||
if userThirdPartyAuth == "" {
|
||||
slog.Error("User has no thirdPartyAuth token..")
|
||||
return JFContentFindResponse{}, errors.New("user has no jellyfin auth token")
|
||||
}
|
||||
if contentType == "" || contentName == "" {
|
||||
slog.Error("Bad request", "content_type", contentType, "content_name", contentName)
|
||||
return JFContentFindResponse{}, errors.New("content type or name not provided")
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type JellyfinSeriesSeasonsResponse struct {
|
||||
Items []JellyfinSeriesSeasonItem `json:"Items"`
|
||||
}
|
||||
|
||||
type JellyfinSeriesSeasonItem struct {
|
||||
JellyfinItems
|
||||
// aka the season number
|
||||
IndexNumber int `json:"IndexNumber"`
|
||||
}
|
||||
|
||||
type JellyfinSeriesEpisodesResponse struct {
|
||||
Items []JellyfinSeriesEpisodeItem `json:"Items"`
|
||||
}
|
||||
|
||||
type JellyfinSeriesEpisodeItem struct {
|
||||
JellyfinItems
|
||||
// the episode number
|
||||
IndexNumber int `json:"IndexNumber"`
|
||||
// the episodes season number
|
||||
ParentIndexNumber int `json:"ParentIndexNumber"`
|
||||
}
|
||||
|
||||
type JellyfinSyncResponse struct {
|
||||
JobId string `json:"jobId"`
|
||||
}
|
||||
|
||||
// Perform the jellyfin sync.
|
||||
// Gets each type of media separately from jellyfin and attempts to import them.
|
||||
// Errors are added silently to the job.
|
||||
func startJellyfinSync(
|
||||
db *gorm.DB,
|
||||
jobId string,
|
||||
userId uint,
|
||||
username string,
|
||||
userThirdPartyId string,
|
||||
userThirdPartyAuth string,
|
||||
) {
|
||||
// Get played movies
|
||||
updateJobCurrentTask(jobId, userId, "syncing movies")
|
||||
playedMovies := new(JellyfinItemSearchResponse)
|
||||
err := jellyfinAPIRequest(
|
||||
"GET",
|
||||
"/Users/"+userThirdPartyId+"/Items",
|
||||
map[string]string{
|
||||
"Filters": "IsPlayed",
|
||||
"IncludeItemTypes": "Movie",
|
||||
"Fields": "ProviderIds",
|
||||
"Recursive": "true",
|
||||
},
|
||||
username,
|
||||
userThirdPartyAuth,
|
||||
&playedMovies,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Jellyfin API request failed", "error", err)
|
||||
addJobError(jobId, userId, "failed to get jellyfin response for movies")
|
||||
} else {
|
||||
if len(playedMovies.Items) <= 0 {
|
||||
slog.Info("jellyfinSyncWatched: User has no played movies.", "user_id", userId)
|
||||
} else {
|
||||
for _, v := range playedMovies.Items {
|
||||
slog.Info("jellyfinSyncWatched: Importing played movie.", "movie_name", v.Name, "user_id", userId)
|
||||
slog.Debug("jellyfinSyncWatched: Importing played movie.", "full_item", v, "user_id", userId)
|
||||
|
||||
// 1. Ensure we have a tmdbId
|
||||
if v.ProviderIds.Tmdb == "" {
|
||||
slog.Error("jellyfinSyncWatched: Movie to import does not have a tmdb id.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+v.Name)
|
||||
continue
|
||||
}
|
||||
tmdbId, err := strconv.Atoi(v.ProviderIds.Tmdb)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Movie to import does not have a parseable (to int) tmdb id.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (tmdbId was not parseable): "+v.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
updateJobCurrentTask(jobId, userId, "syncing "+v.Name)
|
||||
|
||||
// 2. Imported watched movie
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: MOVIE,
|
||||
WatchedDate: v.UserData.LastPlayedDate,
|
||||
}, IMPORTED_WATCHED_JF)
|
||||
if err != nil {
|
||||
if err.Error() == "content already on watched list" {
|
||||
slog.Error("jellyfinSyncWatched: Unique constraint hit.. content must already be on watch list.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
|
||||
} else {
|
||||
slog.Error("jellyfinSyncWatched: Movie failed to import.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (failed when adding to watched list): "+v.Name)
|
||||
}
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED_JF activity
|
||||
if !v.UserData.LastPlayedDate.IsZero() {
|
||||
_, err := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to add dateswatched activity.", "movie_name", v.Name,
|
||||
"movie_ids", v.ProviderIds, "user_id", userId, "date", v.UserData.LastPlayedDate, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get played series
|
||||
// Can't rely on IsPlayed filter, since we want to get partially played series too.
|
||||
updateJobCurrentTask(jobId, userId, "syncing series")
|
||||
allSeries := new(JellyfinItemSearchResponse)
|
||||
err = jellyfinAPIRequest(
|
||||
"GET",
|
||||
"/Users/"+userThirdPartyId+"/Items",
|
||||
map[string]string{
|
||||
"IncludeItemTypes": "Series",
|
||||
"Fields": "ProviderIds,RecursiveItemCount",
|
||||
"Recursive": "true",
|
||||
"IsPlaceHolder": "false",
|
||||
},
|
||||
username,
|
||||
userThirdPartyAuth,
|
||||
&allSeries,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Jellyfin API request failed", "error", err)
|
||||
addJobError(jobId, userId, "failed to get jellyfin response for series")
|
||||
} else {
|
||||
if len(allSeries.Items) <= 0 {
|
||||
slog.Info("jellyfinSyncWatched: No series found.", "user_id", userId)
|
||||
} else {
|
||||
// Import series
|
||||
for _, v := range allSeries.Items {
|
||||
slog.Info("jellyfinSyncWatched: Processing series.", "series_name", v.Name, "user_id", userId)
|
||||
slog.Debug("jellyfinSyncWatched: Processing series.", "full_item", v, "user_id", userId)
|
||||
|
||||
// 1. Make sure show is watched or at least partially watched
|
||||
if !v.UserData.Played && v.UserData.PlayedPercentage <= 0 && v.RecursiveItemCount == v.UserData.UnplayedItemCount {
|
||||
slog.Debug("jellyfinSyncWatched: Skipping unwatched series:", "series_name", v.Name, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 1.1. Ensure we have a tmdbId
|
||||
if v.ProviderIds.Tmdb == "" {
|
||||
slog.Error("jellyfinSyncWatched: Series to import does not have a tmdb id.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "series could not be imported (no tmdbId present): "+v.Name)
|
||||
continue
|
||||
}
|
||||
tmdbId, err := strconv.Atoi(v.ProviderIds.Tmdb)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Series to import does not have a parseable (to int) tmdb id.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "series could not be imported (tmdbId was not parseable): "+v.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
updateJobCurrentTask(jobId, userId, "syncing serie "+v.Name)
|
||||
|
||||
// 2. Imported watched series
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: SHOW,
|
||||
WatchedDate: v.UserData.LastPlayedDate,
|
||||
}, IMPORTED_WATCHED_JF)
|
||||
if err != nil {
|
||||
if err.Error() == "content already on watched list" {
|
||||
slog.Info("jellyfinSyncWatched: Unique constraint hit.. content must already be on watch list.",
|
||||
"series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "watched_id", w.ID)
|
||||
} else {
|
||||
slog.Error("jellyfinSyncWatched: Series failed to import.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "series could not be imported (failed when adding to watched list): "+v.Name)
|
||||
}
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED activity (only if no err above, show also must not have already been on our list)
|
||||
if !v.UserData.LastPlayedDate.IsZero() {
|
||||
_, err := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to add dateswatched activity.", "series_name", v.Name,
|
||||
"series_ids", v.ProviderIds, "user_id", userId, "date", v.UserData.LastPlayedDate, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Import watched seasons for this serie
|
||||
// Get all show seasons (filtering isPlayed doesn't seem to be a thing, so we will have to do that ourselves)
|
||||
seriesSeasons := new(JellyfinSeriesSeasonsResponse)
|
||||
err = jellyfinAPIRequest(
|
||||
"GET",
|
||||
"/Shows/"+v.Id+"/Seasons",
|
||||
map[string]string{
|
||||
"UserId": userThirdPartyId,
|
||||
"Fields": "ProviderIds",
|
||||
"IsPlaceHolder": "false",
|
||||
},
|
||||
username,
|
||||
userThirdPartyAuth,
|
||||
&seriesSeasons,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to fetch series seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series seasons could not be imported (request failed): "+v.Name)
|
||||
} else if len(seriesSeasons.Items) <= 0 {
|
||||
slog.Info("jellyfinSyncWatched: Series has no seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
} else {
|
||||
for _, vs := range seriesSeasons.Items {
|
||||
slog.Debug("jellyfinSyncWatched: Processing a season.", "full_item", v, "user_id", userId)
|
||||
if !vs.UserData.Played {
|
||||
slog.Debug("jellyfinSyncWatched: Skipping import of unplayed season.", "series_name", v.Name, "season_num", vs.IndexNumber, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "syncing "+v.Name+" season "+strconv.Itoa(vs.IndexNumber))
|
||||
_, err = addWatchedSeason(db, userId, WatchedSeasonAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: vs.IndexNumber,
|
||||
Status: FINISHED,
|
||||
addActivity: SEASON_ADDED_JF,
|
||||
addActivityDate: vs.UserData.LastPlayedDate,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to fetch series seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series season could not be imported (addWatchedSeason request failed): "+v.Name+" season "+strconv.Itoa(vs.IndexNumber))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Import watched episodes for this serie
|
||||
// Gets all show episodes (filtering isPlayed doesn't seem to be a thing, so we will have to do that ourselves)
|
||||
seriesEpisodes := new(JellyfinSeriesEpisodesResponse)
|
||||
err = jellyfinAPIRequest(
|
||||
"GET",
|
||||
"/Shows/"+v.Id+"/Episodes",
|
||||
map[string]string{
|
||||
"UserId": userThirdPartyId,
|
||||
"Fields": "ProviderIds",
|
||||
"IsPlaceHolder": "false",
|
||||
},
|
||||
username,
|
||||
userThirdPartyAuth,
|
||||
&seriesEpisodes,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to fetch series episodes.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series episodes could not be imported (request failed): "+v.Name)
|
||||
} else if len(seriesEpisodes.Items) <= 0 {
|
||||
slog.Info("jellyfinSyncWatched: Series has no episodes.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
} else {
|
||||
for _, vs := range seriesEpisodes.Items {
|
||||
slog.Debug("jellyfinSyncWatched: Processing an episode.", "full_item", v, "user_id", userId)
|
||||
if !vs.UserData.Played {
|
||||
slog.Debug("jellyfinSyncWatched: Skipping import of unplayed episode.", "series_name", v.Name, "season_num", vs.ParentIndexNumber, "episode_num", vs.IndexNumber, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "syncing "+v.Name+" season "+strconv.Itoa(vs.ParentIndexNumber)+" episode "+strconv.Itoa(vs.IndexNumber))
|
||||
_, err = addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: vs.ParentIndexNumber,
|
||||
EpisodeNumber: vs.IndexNumber,
|
||||
Status: FINISHED,
|
||||
addActivity: EPISODE_ADDED_JF,
|
||||
addActivityDate: vs.UserData.LastPlayedDate,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to import series episode.", "series_name", v.Name, "season_num", vs.ParentIndexNumber, "episode_num", vs.IndexNumber, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series episode could not be imported (addWatchedEpisode request failed): "+v.Name+" "+vs.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateJobStatus(jobId, userId, JOB_DONE)
|
||||
}
|
||||
|
||||
func jellyfinSyncWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
userType UserType,
|
||||
username string,
|
||||
userThirdPartyId string,
|
||||
userThirdPartyAuth string,
|
||||
) (JellyfinSyncResponse, error) {
|
||||
jobId, err := addJob("jf_sync", userId)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to create a job", "error", err)
|
||||
return JellyfinSyncResponse{}, errors.New("failed to create job")
|
||||
}
|
||||
|
||||
updateJobStatus(jobId, userId, JOB_RUNNING)
|
||||
|
||||
go startJellyfinSync(
|
||||
db,
|
||||
jobId,
|
||||
userId,
|
||||
username,
|
||||
userThirdPartyId,
|
||||
userThirdPartyAuth,
|
||||
)
|
||||
|
||||
return JellyfinSyncResponse{JobId: jobId}, nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// We will use jobs only for storing/retrieving active job statuses for the client.
|
||||
// Running the job will be done wherever needed, but isn't handled here.
|
||||
// When starting a job elsewhere, we should first add a job here as active to get an `id`,
|
||||
// this id should be used to update the active job so the client can request job status updates.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
type JobStatus string
|
||||
|
||||
var (
|
||||
JOB_CREATED JobStatus = "CREATED"
|
||||
JOB_RUNNING JobStatus = "RUNNING"
|
||||
JOB_DONE JobStatus = "DONE"
|
||||
JOB_CANCELLED JobStatus = "CANCELLED"
|
||||
)
|
||||
|
||||
type Job struct {
|
||||
// We can give the job a name simply for showing on the client.
|
||||
Name string `json:"name"`
|
||||
// The current status of the job.
|
||||
Status JobStatus `json:"status"`
|
||||
// The current task we are performing inside the job.
|
||||
// Just so we can portray progress on the client by displaying the current task.
|
||||
CurrentTask string `json:"currentTask,omitempty"`
|
||||
// Errors that occurred in the task
|
||||
Errors []string `json:"errors"`
|
||||
// Stored for access control.
|
||||
UserId uint `json:"-"`
|
||||
}
|
||||
|
||||
var activeJobs = make(map[string]*Job)
|
||||
|
||||
// Add a job to our activeJobs map.
|
||||
// Returns id of job on success, or error if failed to add.
|
||||
func addJob(name string, userId uint) (string, error) {
|
||||
idk, err := generateString(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, ok := activeJobs[idk]
|
||||
if ok {
|
||||
// Lets just hope this doesn't happen, may the odds be with us.
|
||||
return "", errors.New("job already exists with id generated, please try again")
|
||||
}
|
||||
activeJobs[idk] = &Job{
|
||||
Name: name,
|
||||
Status: JOB_CREATED,
|
||||
UserId: userId,
|
||||
}
|
||||
return idk, nil
|
||||
}
|
||||
|
||||
func rmJob(id string, userId uint) {
|
||||
slog.Debug("rmJob: Removing a job.", "id", id)
|
||||
v, ok := activeJobs[id]
|
||||
if ok && v.UserId == userId {
|
||||
delete(activeJobs, id)
|
||||
slog.Debug("rmJob: Removed a job.", "id", id)
|
||||
return
|
||||
}
|
||||
slog.Debug("rmJob: Job to remove does not exist (or not owned by this user).", "id", id, "user_id", userId)
|
||||
}
|
||||
|
||||
// Get a job.
|
||||
// Returns job if found, otherwise errors if job does not exist.
|
||||
func getJob(id string, userId uint) (*Job, error) {
|
||||
j, ok := activeJobs[id]
|
||||
if ok {
|
||||
// Ensure user requesting a job, owns the job.
|
||||
if j.UserId != userId {
|
||||
slog.Warn("getJob: A user tried to access a job they do not own.", "user_id", userId, "job_id", id)
|
||||
return &Job{}, errors.New("job does not exist")
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
return &Job{}, errors.New("job does not exist")
|
||||
}
|
||||
|
||||
// Update a jobs status.
|
||||
func updateJobStatus(id string, userId uint, status JobStatus) error {
|
||||
j, err := getJob(id, userId)
|
||||
if err != nil {
|
||||
slog.Error("updateJobStatus: Failed!", "status", status, "error", err)
|
||||
return err
|
||||
}
|
||||
j.Status = status
|
||||
// If job is set to done, remove it after 1 minute.
|
||||
if status == JOB_DONE {
|
||||
slog.Debug("updateJobStatus: Job set to done. Will be removed after 1m.", "id", id)
|
||||
go func() {
|
||||
time.Sleep(1 * time.Minute)
|
||||
slog.Debug("updateJobStatus: Job done. waited 1m.. removing job now.", "id", id)
|
||||
rmJob(id, userId)
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update a jobs current task.
|
||||
func updateJobCurrentTask(id string, userId uint, ct string) error {
|
||||
j, err := getJob(id, userId)
|
||||
if err != nil {
|
||||
slog.Error("updateJobCurrentTask: Failed!", "ct", ct, "error", err)
|
||||
return err
|
||||
}
|
||||
j.CurrentTask = ct
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add an error to a job.
|
||||
func addJobError(id string, userId uint, e string) error {
|
||||
j, err := getJob(id, userId)
|
||||
if err != nil {
|
||||
slog.Error("updateJobCurrentTask: Failed!", "e", e, "error", err)
|
||||
return err
|
||||
}
|
||||
j.Errors = append(j.Errors, e)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PlexLoginRequest struct {
|
||||
AuthToken string `json:"token" binding:"required"`
|
||||
ClientIdentifier string `json:"clientIdentifier" binding:"required"`
|
||||
}
|
||||
|
||||
type PlexUser struct {
|
||||
Id uint64 `json:"id" binding:"required"`
|
||||
Uuid string `json:"uuid" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
}
|
||||
|
||||
type PlexAccountResponse struct {
|
||||
User PlexUser `json:"user" binding:"required"`
|
||||
}
|
||||
|
||||
type PlexIdentity struct {
|
||||
MediaContainer struct {
|
||||
MachineIdentifier string `json:"machineIdentifier"`
|
||||
} `json:"MediaContainer"`
|
||||
}
|
||||
|
||||
type PlexHostConfigUpdateResponse struct {
|
||||
PLEX_MACHINE_ID string
|
||||
}
|
||||
|
||||
type PlexUsersResponse struct {
|
||||
XMLName xml.Name `xml:"MediaContainer"`
|
||||
Text string `xml:",chardata"`
|
||||
FriendlyName string `xml:"friendlyName,attr"`
|
||||
Identifier string `xml:"identifier,attr"`
|
||||
MachineIdentifier string `xml:"machineIdentifier,attr"`
|
||||
TotalSize string `xml:"totalSize,attr"`
|
||||
Size string `xml:"size,attr"`
|
||||
User []struct {
|
||||
Text string `xml:",chardata"`
|
||||
ID string `xml:"id,attr"`
|
||||
Title string `xml:"title,attr"`
|
||||
Username string `xml:"username,attr"`
|
||||
Email string `xml:"email,attr"`
|
||||
RecommendationsPlaylistId string `xml:"recommendationsPlaylistId,attr"`
|
||||
Thumb string `xml:"thumb,attr"`
|
||||
Protected string `xml:"protected,attr"`
|
||||
Home string `xml:"home,attr"`
|
||||
AllowTuners string `xml:"allowTuners,attr"`
|
||||
AllowSync string `xml:"allowSync,attr"`
|
||||
AllowCameraUpload string `xml:"allowCameraUpload,attr"`
|
||||
AllowChannels string `xml:"allowChannels,attr"`
|
||||
AllowSubtitleAdmin string `xml:"allowSubtitleAdmin,attr"`
|
||||
FilterAll string `xml:"filterAll,attr"`
|
||||
FilterMovies string `xml:"filterMovies,attr"`
|
||||
FilterMusic string `xml:"filterMusic,attr"`
|
||||
FilterPhotos string `xml:"filterPhotos,attr"`
|
||||
FilterTelevision string `xml:"filterTelevision,attr"`
|
||||
Restricted string `xml:"restricted,attr"`
|
||||
Server []struct {
|
||||
Text string `xml:",chardata"`
|
||||
ID string `xml:"id,attr"`
|
||||
ServerId string `xml:"serverId,attr"`
|
||||
MachineIdentifier string `xml:"machineIdentifier,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
LastSeenAt string `xml:"lastSeenAt,attr"`
|
||||
NumLibraries string `xml:"numLibraries,attr"`
|
||||
AllLibraries string `xml:"allLibraries,attr"`
|
||||
Owned string `xml:"owned,attr"`
|
||||
Pending string `xml:"pending,attr"`
|
||||
} `xml:"Server"`
|
||||
} `xml:"User"`
|
||||
}
|
||||
|
||||
// Plex get libraries response
|
||||
// /library/sections
|
||||
type PlexLibrariesResponse struct {
|
||||
MediaContainer struct {
|
||||
Size int `json:"size"`
|
||||
AllowSync bool `json:"allowSync"`
|
||||
Title1 string `json:"title1"`
|
||||
Directory []struct {
|
||||
AllowSync bool `json:"allowSync"`
|
||||
Art string `json:"art"`
|
||||
Composite string `json:"composite"`
|
||||
Filters bool `json:"filters"`
|
||||
Refreshing bool `json:"refreshing"`
|
||||
Thumb string `json:"thumb"`
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Agent string `json:"agent"`
|
||||
Scanner string `json:"scanner"`
|
||||
Language string `json:"language"`
|
||||
UUID string `json:"uuid"`
|
||||
UpdatedAt int `json:"updatedAt"`
|
||||
CreatedAt int `json:"createdAt"`
|
||||
ScannedAt int `json:"scannedAt"`
|
||||
Content bool `json:"content"`
|
||||
Directory bool `json:"directory"`
|
||||
ContentChangedAt int64 `json:"contentChangedAt"`
|
||||
Hidden int `json:"hidden"`
|
||||
Location []struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"`
|
||||
} `json:"Location"`
|
||||
} `json:"Directory"`
|
||||
} `json:"MediaContainer"`
|
||||
}
|
||||
|
||||
// Plex get all library items response (with includeGuids parameter)
|
||||
// /library/sections/{sectionId}/all
|
||||
type PlexLibraryItemsResponse struct {
|
||||
MediaContainer struct {
|
||||
Size int `json:"size"`
|
||||
AllowSync bool `json:"allowSync"`
|
||||
Art string `json:"art"`
|
||||
Identifier string `json:"identifier"`
|
||||
LibrarySectionID int `json:"librarySectionID"`
|
||||
LibrarySectionTitle string `json:"librarySectionTitle"`
|
||||
LibrarySectionUUID string `json:"librarySectionUUID"`
|
||||
MediaTagPrefix string `json:"mediaTagPrefix"`
|
||||
MediaTagVersion int `json:"mediaTagVersion"`
|
||||
Thumb string `json:"thumb"`
|
||||
Title1 string `json:"title1"`
|
||||
Title2 string `json:"title2"`
|
||||
ViewGroup string `json:"viewGroup"`
|
||||
ViewMode int `json:"viewMode"`
|
||||
Metadata []struct {
|
||||
RatingKey string `json:"ratingKey"`
|
||||
Key string `json:"key"`
|
||||
GUID string `json:"guid"` // Plex guid
|
||||
Slug string `json:"slug"`
|
||||
Studio string `json:"studio"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
ContentRating string `json:"contentRating"`
|
||||
Summary string `json:"summary"`
|
||||
Rating float64 `json:"rating"`
|
||||
UserRating float64 `json:"userRating"`
|
||||
AudienceRating float64 `json:"audienceRating"`
|
||||
ViewCount int `json:"viewCount,omitempty"`
|
||||
LastViewedAt int64 `json:"lastViewedAt,omitempty"`
|
||||
Year int `json:"year"`
|
||||
Tagline string `json:"tagline"`
|
||||
Thumb string `json:"thumb"`
|
||||
Art string `json:"art"`
|
||||
Duration int `json:"duration"`
|
||||
OriginallyAvailableAt string `json:"originallyAvailableAt"`
|
||||
AddedAt int `json:"addedAt"`
|
||||
UpdatedAt int `json:"updatedAt"`
|
||||
AudienceRatingImage string `json:"audienceRatingImage"`
|
||||
HasPremiumPrimaryExtra string `json:"hasPremiumPrimaryExtra"`
|
||||
RatingImage string `json:"ratingImage"`
|
||||
LeafCount int `json:"leafCount,omitempty"`
|
||||
ViewedLeafCount int `json:"viewedLeafCount,omitempty"`
|
||||
Media []struct {
|
||||
ID int `json:"id"`
|
||||
Duration int `json:"duration"`
|
||||
Bitrate int `json:"bitrate"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
AspectRatio float64 `json:"aspectRatio"`
|
||||
AudioChannels int `json:"audioChannels"`
|
||||
AudioCodec string `json:"audioCodec"`
|
||||
VideoCodec string `json:"videoCodec"`
|
||||
VideoResolution string `json:"videoResolution"`
|
||||
Container string `json:"container"`
|
||||
VideoFrameRate string `json:"videoFrameRate"`
|
||||
VideoProfile string `json:"videoProfile"`
|
||||
Part []struct {
|
||||
ID int `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Duration int `json:"duration"`
|
||||
File string `json:"file"`
|
||||
Size int64 `json:"size"`
|
||||
Container string `json:"container"`
|
||||
HasThumbnail string `json:"hasThumbnail"`
|
||||
VideoProfile string `json:"videoProfile"`
|
||||
} `json:"Part"`
|
||||
} `json:"Media"`
|
||||
// External ids
|
||||
Guid []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"Guid"`
|
||||
Genre []struct {
|
||||
Tag string `json:"tag"`
|
||||
} `json:"Genre"`
|
||||
Country []struct {
|
||||
Tag string `json:"tag"`
|
||||
} `json:"Country"`
|
||||
Director []struct {
|
||||
Tag string `json:"tag"`
|
||||
} `json:"Director"`
|
||||
Writer []struct {
|
||||
Tag string `json:"tag"`
|
||||
} `json:"Writer"`
|
||||
Role []struct {
|
||||
Tag string `json:"tag"`
|
||||
} `json:"Role"`
|
||||
ChapterSource string `json:"chapterSource,omitempty"`
|
||||
} `json:"Metadata"`
|
||||
} `json:"MediaContainer"`
|
||||
}
|
||||
|
||||
// Plex get all children (seasons) for a serie.
|
||||
// /library/metadata/{ratingKey}/children
|
||||
type PlexLibraryItemSeasonsResponse struct {
|
||||
MediaContainer struct {
|
||||
Size int `json:"size"`
|
||||
AllowSync bool `json:"allowSync"`
|
||||
Art string `json:"art"`
|
||||
Identifier string `json:"identifier"`
|
||||
Key string `json:"key"`
|
||||
LibrarySectionID int `json:"librarySectionID"`
|
||||
LibrarySectionTitle string `json:"librarySectionTitle"`
|
||||
LibrarySectionUUID string `json:"librarySectionUUID"`
|
||||
MediaTagPrefix string `json:"mediaTagPrefix"`
|
||||
MediaTagVersion int `json:"mediaTagVersion"`
|
||||
Nocache bool `json:"nocache"`
|
||||
ParentIndex int `json:"parentIndex"`
|
||||
ParentTitle string `json:"parentTitle"`
|
||||
ParentYear int `json:"parentYear"`
|
||||
Summary string `json:"summary"`
|
||||
Theme string `json:"theme"`
|
||||
Thumb string `json:"thumb"`
|
||||
Title1 string `json:"title1"`
|
||||
Title2 string `json:"title2"`
|
||||
ViewGroup string `json:"viewGroup"`
|
||||
ViewMode int `json:"viewMode"`
|
||||
Directory []struct {
|
||||
LeafCount int `json:"leafCount"`
|
||||
Thumb string `json:"thumb"`
|
||||
ViewedLeafCount int `json:"viewedLeafCount"`
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
} `json:"Directory"`
|
||||
Metadata []struct {
|
||||
RatingKey string `json:"ratingKey"`
|
||||
Key string `json:"key"`
|
||||
ParentRatingKey string `json:"parentRatingKey"`
|
||||
GUID string `json:"guid"`
|
||||
ParentGUID string `json:"parentGuid"`
|
||||
ParentSlug string `json:"parentSlug"`
|
||||
ParentStudio string `json:"parentStudio"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
ParentKey string `json:"parentKey"`
|
||||
ParentTitle string `json:"parentTitle"`
|
||||
Summary string `json:"summary"`
|
||||
Index int `json:"index"`
|
||||
ParentIndex int `json:"parentIndex"`
|
||||
UserRating float64 `json:"userRating,omitempty"`
|
||||
LastRatedAt int64 `json:"lastRatedAt,omitempty"`
|
||||
ParentYear int `json:"parentYear"`
|
||||
Thumb string `json:"thumb"`
|
||||
Art string `json:"art"`
|
||||
ParentThumb string `json:"parentThumb"`
|
||||
ParentTheme string `json:"parentTheme"`
|
||||
LeafCount int `json:"leafCount"`
|
||||
ViewedLeafCount int `json:"viewedLeafCount"`
|
||||
AddedAt int `json:"addedAt"`
|
||||
UpdatedAt int `json:"updatedAt"`
|
||||
ViewCount int `json:"viewCount,omitempty"`
|
||||
LastViewedAt int64 `json:"lastViewedAt,omitempty"`
|
||||
} `json:"Metadata"`
|
||||
} `json:"MediaContainer"`
|
||||
}
|
||||
|
||||
// Plex get all children (episodes) for a season.
|
||||
// /library/metadata/{ratingKey}/allLeaves
|
||||
type PlexLibraryItemEpisodesResponse struct {
|
||||
MediaContainer struct {
|
||||
Size int `json:"size"`
|
||||
AllowSync bool `json:"allowSync"`
|
||||
Art string `json:"art"`
|
||||
Identifier string `json:"identifier"`
|
||||
Key string `json:"key"`
|
||||
LibrarySectionID int `json:"librarySectionID"`
|
||||
LibrarySectionTitle string `json:"librarySectionTitle"`
|
||||
LibrarySectionUUID string `json:"librarySectionUUID"`
|
||||
MediaTagPrefix string `json:"mediaTagPrefix"`
|
||||
MediaTagVersion int `json:"mediaTagVersion"`
|
||||
MixedParents bool `json:"mixedParents"`
|
||||
Nocache bool `json:"nocache"`
|
||||
ParentIndex int `json:"parentIndex"`
|
||||
ParentTitle string `json:"parentTitle"`
|
||||
ParentYear int `json:"parentYear"`
|
||||
Theme string `json:"theme"`
|
||||
Title1 string `json:"title1"`
|
||||
Title2 string `json:"title2"`
|
||||
ViewGroup string `json:"viewGroup"`
|
||||
ViewMode int `json:"viewMode"`
|
||||
Metadata []struct {
|
||||
RatingKey string `json:"ratingKey"`
|
||||
Key string `json:"key"`
|
||||
ParentRatingKey string `json:"parentRatingKey"`
|
||||
GrandparentRatingKey string `json:"grandparentRatingKey"`
|
||||
GUID string `json:"guid"`
|
||||
GrandparentSlug string `json:"grandparentSlug"`
|
||||
Studio string `json:"studio"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
GrandparentKey string `json:"grandparentKey"`
|
||||
ParentKey string `json:"parentKey"`
|
||||
GrandparentTitle string `json:"grandparentTitle"`
|
||||
ParentTitle string `json:"parentTitle"`
|
||||
ContentRating string `json:"contentRating"`
|
||||
Summary string `json:"summary"`
|
||||
Index int `json:"index"`
|
||||
ParentIndex int `json:"parentIndex"`
|
||||
AudienceRating float64 `json:"audienceRating"`
|
||||
Year int `json:"year"`
|
||||
Thumb string `json:"thumb"`
|
||||
Art string `json:"art"`
|
||||
ParentThumb string `json:"parentThumb"`
|
||||
GrandparentThumb string `json:"grandparentThumb"`
|
||||
GrandparentArt string `json:"grandparentArt"`
|
||||
GrandparentTheme string `json:"grandparentTheme"`
|
||||
Duration int `json:"duration"`
|
||||
OriginallyAvailableAt string `json:"originallyAvailableAt"`
|
||||
AddedAt int `json:"addedAt"`
|
||||
UpdatedAt int `json:"updatedAt"`
|
||||
AudienceRatingImage string `json:"audienceRatingImage"`
|
||||
ChapterSource string `json:"chapterSource"`
|
||||
TitleSort string `json:"titleSort,omitempty"`
|
||||
UserRating float64 `json:"userRating,omitempty"`
|
||||
ViewCount int `json:"viewCount,omitempty"`
|
||||
LastViewedAt int64 `json:"lastViewedAt,omitempty"`
|
||||
LastRatedAt int64 `json:"lastRatedAt,omitempty"`
|
||||
} `json:"Metadata"`
|
||||
} `json:"MediaContainer"`
|
||||
}
|
||||
|
||||
// Response from clients.plex.tv/api/v2/resources
|
||||
// Used to get users auth token for home plex server.
|
||||
type PlexClientResources []struct {
|
||||
Name string `json:"name"`
|
||||
Product string `json:"product"`
|
||||
ProductVersion string `json:"productVersion"`
|
||||
Platform string `json:"platform"`
|
||||
PlatformVersion string `json:"platformVersion"`
|
||||
Device string `json:"device"`
|
||||
ClientIdentifier string `json:"clientIdentifier"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastSeenAt time.Time `json:"lastSeenAt"`
|
||||
Provides string `json:"provides"`
|
||||
OwnerID interface{} `json:"ownerId"`
|
||||
SourceTitle interface{} `json:"sourceTitle"`
|
||||
PublicAddress string `json:"publicAddress"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
Owned bool `json:"owned"`
|
||||
Home bool `json:"home"`
|
||||
Synced bool `json:"synced"`
|
||||
Relay bool `json:"relay"`
|
||||
Presence bool `json:"presence"`
|
||||
HTTPSRequired bool `json:"httpsRequired"`
|
||||
PublicAddressMatches bool `json:"publicAddressMatches"`
|
||||
DNSRebindingProtection bool `json:"dnsRebindingProtection"`
|
||||
NatLoopbackSupported bool `json:"natLoopbackSupported"`
|
||||
Connections []struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
URI string `json:"uri"`
|
||||
Local bool `json:"local"`
|
||||
Relay bool `json:"relay"`
|
||||
IPv6 bool `json:"IPv6"`
|
||||
} `json:"connections"`
|
||||
}
|
||||
|
||||
// Plex access middleware, ensures user is a Plex user.
|
||||
// To be ran after AuthRequired middleware with extra data.
|
||||
func PlexAccessRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
slog.Debug("PlexAccessRequired middleware hit", "user_id", userId)
|
||||
userType := c.MustGet("userType").(UserType)
|
||||
if Config.PLEX_HOST == "" || Config.PLEX_MACHINE_ID == "" {
|
||||
slog.Error("PlexAccessRequired: Plex has not been configured.", "user_id", userId)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
if userType != PLEX_USER {
|
||||
slog.Error("PlexAccessRequired: User is not a Plex user..", "user_id", userId, "user_type", userType)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
userPlexService := new(UserServices)
|
||||
if res := db.Where("user_id = ? AND name = ?", userId, "plex").Take(&userPlexService); res.Error != nil {
|
||||
slog.Error("PlexAccessRequired: Failed when attempting to get users plex service integration..", "user_id", userId, "user_type", userType)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
if userPlexService.ClientID == "" || userPlexService.AuthToken == "" || userPlexService.AuthToken2 == "" {
|
||||
slog.Error("PlexAccessRequired: User has missing details from service (clientId, authToken or authToken2)..", "user_id", userId, "client_id", userPlexService.ClientID)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
c.Set("plexAuthToken", userPlexService.AuthToken)
|
||||
c.Set("plexLocalAuthToken", userPlexService.AuthToken2)
|
||||
}
|
||||
}
|
||||
|
||||
func getPlexIdentity(host string) (PlexIdentity, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", host+"/identity", nil)
|
||||
if err != nil {
|
||||
return PlexIdentity{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexIdentity{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexIdentity{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pi PlexIdentity
|
||||
err = json.Unmarshal(body, &pi)
|
||||
if err != nil {
|
||||
return PlexIdentity{}, err
|
||||
}
|
||||
return pi, nil
|
||||
}
|
||||
|
||||
func fetchPlexAccountFromToken(token string) (PlexUser, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://plex.tv/users/account.json", nil)
|
||||
if err != nil {
|
||||
return PlexUser{}, err
|
||||
}
|
||||
req.Header.Set("X-Plex-Token", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexUser{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexUser{}, err
|
||||
}
|
||||
slog.Info(string(body))
|
||||
defer resp.Body.Close()
|
||||
var pa PlexAccountResponse
|
||||
err = json.Unmarshal(body, &pa)
|
||||
if err != nil {
|
||||
return PlexUser{}, err
|
||||
}
|
||||
return pa.User, nil
|
||||
}
|
||||
|
||||
// Update plex host setting
|
||||
func updateConfigPlexHost(v string) (PlexHostConfigUpdateResponse, error) {
|
||||
Config.PLEX_HOST = v
|
||||
if Config.PLEX_HOST != "" {
|
||||
pi, err := getPlexIdentity(Config.PLEX_HOST)
|
||||
if err != nil {
|
||||
slog.Error("updateConfigPlexHost: Failed to get plex server identity!", "error", err)
|
||||
return PlexHostConfigUpdateResponse{}, errors.New("failed to get Plex server identity. Please try setting the Plex Host again or setting PLEX_MACHINE_ID manually in your config file")
|
||||
}
|
||||
if pi.MediaContainer.MachineIdentifier == "" {
|
||||
slog.Error("updateConfigPlexHost: Plex server identity response had no machine id!", "response", pi)
|
||||
return PlexHostConfigUpdateResponse{}, errors.New("got Plex server identity, but no machine id was found")
|
||||
}
|
||||
Config.PLEX_MACHINE_ID = pi.MediaContainer.MachineIdentifier
|
||||
} else {
|
||||
Config.PLEX_MACHINE_ID = ""
|
||||
}
|
||||
if err := writeConfig(); err != nil {
|
||||
slog.Error("updateConfigPlexHost: Failed to write updated config to file!", "err", err)
|
||||
return PlexHostConfigUpdateResponse{}, errors.New("failed to write config")
|
||||
}
|
||||
return PlexHostConfigUpdateResponse{PLEX_MACHINE_ID: Config.PLEX_MACHINE_ID}, nil
|
||||
}
|
||||
|
||||
// If a plex user has access to our home plex server (PLEX_HOST).
|
||||
func plexUserHasAccessToPlexHost(token string) error {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://plex.tv/api/users", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Plex-Token", token)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pa PlexUsersResponse
|
||||
err = xml.Unmarshal(body, &pa)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(pa.User) <= 0 {
|
||||
return errors.New("found no users in response")
|
||||
}
|
||||
|
||||
// Now check if any of the users servers include our home server machine id
|
||||
homeServerFound := false
|
||||
userLoop:
|
||||
for _, user := range pa.User {
|
||||
for _, server := range user.Server {
|
||||
if server.MachineIdentifier == Config.PLEX_MACHINE_ID {
|
||||
slog.Debug("plexUserHasAccessToPlexHost: Processing a server.", "server", server.MachineIdentifier)
|
||||
homeServerFound = true
|
||||
break userLoop
|
||||
}
|
||||
}
|
||||
if homeServerFound {
|
||||
break
|
||||
}
|
||||
}
|
||||
if homeServerFound {
|
||||
return nil
|
||||
}
|
||||
return errors.New("user does not have access to home plex server")
|
||||
}
|
||||
|
||||
func getPlexLibraries(plexAuth string) (PlexLibrariesResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/sections", nil)
|
||||
if err != nil {
|
||||
return PlexLibrariesResponse{}, err
|
||||
}
|
||||
req.Header.Add("Accept", "application/json")
|
||||
req.Header.Set("X-Plex-Token", plexAuth)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexLibrariesResponse{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexLibrariesResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pl PlexLibrariesResponse
|
||||
err = json.Unmarshal(body, &pl)
|
||||
if err != nil {
|
||||
return PlexLibrariesResponse{}, err
|
||||
}
|
||||
return pl, nil
|
||||
}
|
||||
|
||||
func getPlexLibraryItems(plexAuth string, libraryKey string) (PlexLibraryItemsResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/sections/"+libraryKey+"/all?includeGuids=1", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemsResponse{}, err
|
||||
}
|
||||
req.Header.Add("Accept", "application/json")
|
||||
req.Header.Set("X-Plex-Token", plexAuth)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexLibraryItemsResponse{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexLibraryItemsResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pl PlexLibraryItemsResponse
|
||||
err = json.Unmarshal(body, &pl)
|
||||
if err != nil {
|
||||
return PlexLibraryItemsResponse{}, err
|
||||
}
|
||||
return pl, nil
|
||||
}
|
||||
|
||||
func getPlexLibraryItemSeasons(plexAuth string, ratingKey string) (PlexLibraryItemSeasonsResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/metadata/"+ratingKey+"/children", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemSeasonsResponse{}, err
|
||||
}
|
||||
req.Header.Add("Accept", "application/json")
|
||||
req.Header.Set("X-Plex-Token", plexAuth)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexLibraryItemSeasonsResponse{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexLibraryItemSeasonsResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pl PlexLibraryItemSeasonsResponse
|
||||
err = json.Unmarshal(body, &pl)
|
||||
if err != nil {
|
||||
return PlexLibraryItemSeasonsResponse{}, err
|
||||
}
|
||||
return pl, nil
|
||||
}
|
||||
|
||||
func getPlexLibraryItemEpisodes(plexAuth string, ratingKey string) (PlexLibraryItemEpisodesResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/metadata/"+ratingKey+"/allLeaves", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemEpisodesResponse{}, err
|
||||
}
|
||||
req.Header.Add("Accept", "application/json")
|
||||
req.Header.Set("X-Plex-Token", plexAuth)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexLibraryItemEpisodesResponse{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexLibraryItemEpisodesResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pl PlexLibraryItemEpisodesResponse
|
||||
err = json.Unmarshal(body, &pl)
|
||||
if err != nil {
|
||||
return PlexLibraryItemEpisodesResponse{}, err
|
||||
}
|
||||
return pl, nil
|
||||
}
|
||||
|
||||
// Gets users auth token for local plex server,
|
||||
// so they can authenticate against it for api requests.
|
||||
func getPlexHomeServerAuthToken(plexAuth string, userClientId string) (string, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://clients.plex.tv/api/v2/resources", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Add("Accept", "application/json")
|
||||
req.Header.Set("X-Plex-Token", plexAuth)
|
||||
req.Header.Set("X-Plex-Client-Identifier", userClientId)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pl PlexClientResources
|
||||
err = json.Unmarshal(body, &pl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
authToken := ""
|
||||
for _, v := range pl {
|
||||
if v.ClientIdentifier == Config.PLEX_MACHINE_ID {
|
||||
slog.Debug("getPlexHomeServerAuthToken: Found entry with clientIdentifier matching home server machine id.")
|
||||
if v.AccessToken == "" {
|
||||
slog.Warn("getPlexHomeServerAuthToken: Matching entry has no AccessToken!")
|
||||
continue
|
||||
}
|
||||
authToken = v.AccessToken
|
||||
}
|
||||
}
|
||||
return authToken, nil
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PlexSyncResponse struct {
|
||||
JobId string `json:"jobId"`
|
||||
}
|
||||
|
||||
// Perform a Plex sync.
|
||||
// Errors are added silently to the job.
|
||||
func startPlexSync(
|
||||
db *gorm.DB,
|
||||
jobId string,
|
||||
userId uint,
|
||||
userPlexLocalAuth string,
|
||||
) {
|
||||
updateJobCurrentTask(jobId, userId, "fetching libraries")
|
||||
libraries, err := getPlexLibraries(userPlexLocalAuth)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch libraries", "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "failed to get plex libraries")
|
||||
updateJobStatus(jobId, userId, JOB_DONE)
|
||||
return
|
||||
}
|
||||
for _, library := range libraries.MediaContainer.Directory {
|
||||
slog.Debug("plexSyncWatched: Processing a library", "library_title", library.Title, "library_type", library.Type, "user_id", userId)
|
||||
if library.Type == "movie" {
|
||||
updateJobCurrentTask(jobId, userId, "importing movies from "+library.Title)
|
||||
movies, err := getPlexLibraryItems(userPlexLocalAuth, library.Key)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch movies from library", "library", library.Key, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "failed to fetch movies from library "+library.Key)
|
||||
continue
|
||||
}
|
||||
for _, movie := range movies.MediaContainer.Metadata {
|
||||
if movie.ViewCount == 0 {
|
||||
// Not viewed and not rated, skip importing
|
||||
slog.Debug("plexSyncWatched: Skipping unwatched movie:", "movie_name", movie.Title, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "importing movie "+movie.Title)
|
||||
slog.Info("plexSyncWatched: Importing movie.", "movie_name", movie.Title, "user_id", userId)
|
||||
|
||||
// Find tmdb id
|
||||
if len(movie.Guid) <= 0 {
|
||||
slog.Error("plexSyncWatched: Movie to import does not have any external guids.", "movie_name", movie.Title, "movie_id", movie.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (no external ids present): "+movie.Title)
|
||||
continue
|
||||
}
|
||||
tmdbIdStr := ""
|
||||
for _, v := range movie.Guid {
|
||||
if strings.HasPrefix(v.ID, "tmdb://") {
|
||||
tmdbIdStr = v.ID[7:]
|
||||
break
|
||||
}
|
||||
}
|
||||
if tmdbIdStr == "" {
|
||||
slog.Error("plexSyncWatched: Movie to import does not have a tmdb id.", "movie_name", movie.Title, "tmdb_id_str", tmdbIdStr, "movie_id", movie.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+movie.Title)
|
||||
continue
|
||||
}
|
||||
tmdbId, err := strconv.Atoi(tmdbIdStr)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Movie to import does not have a parseable (to int) tmdb id.", "movie_name", movie.Title, "tmdb_id_str", tmdbIdStr, "movie_id", movie.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (tmdbId was not parseable): "+movie.Title)
|
||||
continue
|
||||
}
|
||||
|
||||
lastViewedAt := time.Unix(movie.LastViewedAt, 0)
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: MOVIE,
|
||||
Rating: int8(movie.UserRating),
|
||||
WatchedDate: lastViewedAt,
|
||||
}, IMPORTED_WATCHED_PLEX)
|
||||
if err != nil {
|
||||
if err.Error() == "content already on watched list" {
|
||||
slog.Error("plexSyncWatched: unique constraint hit. movie must already be on watch list", "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Error("plexSyncWatched: Failed to add movie as watched", "error", err)
|
||||
addJobError(jobId, userId, "failed to add movie "+movie.Title)
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
|
||||
if !lastViewedAt.IsZero() {
|
||||
_, err := addActivity(db, userId, ActivityAddRequest{
|
||||
WatchedID: w.ID,
|
||||
Type: IMPORTED_ADDED_WATCHED_PLEX,
|
||||
CustomDate: &lastViewedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to add dateswatched activity.", "movie_name", movie.Title,
|
||||
"movie_id", movie.GUID, "user_id", userId, "date", lastViewedAt, "unparsed_date", movie.LastViewedAt, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if library.Type == "show" {
|
||||
updateJobCurrentTask(jobId, userId, "importing tv shows from "+library.Title)
|
||||
shows, err := getPlexLibraryItems(userPlexLocalAuth, library.Key)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch shows from library", "library", library.Key, "error", err)
|
||||
addJobError(jobId, userId, "failed to fetch shows from library "+library.Key)
|
||||
continue
|
||||
}
|
||||
for _, show := range shows.MediaContainer.Metadata {
|
||||
if show.ViewedLeafCount != show.LeafCount {
|
||||
// Not viewed, skip importing
|
||||
// (could be improved to set status as watching when viewedLeafCount is higher than 0)
|
||||
slog.Debug("plexSyncWatched: Skipping unwatched show:", "show_name", show.Title, "leaf_count", show.LeafCount, "viewed_leaf_count", show.ViewedLeafCount, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "importing show "+show.Title)
|
||||
slog.Info("plexSyncWatched: Importing show.", "show_name", show.Title, "user_id", userId)
|
||||
|
||||
tmdbIdStr := ""
|
||||
for _, v := range show.Guid {
|
||||
if strings.HasPrefix(v.ID, "tmdb://") {
|
||||
tmdbIdStr = v.ID[7:]
|
||||
break
|
||||
}
|
||||
}
|
||||
if tmdbIdStr == "" {
|
||||
slog.Error("plexSyncWatched: Show to import does not have a tmdb id.", "show_name", show.Title, "tmdb_id_str", tmdbIdStr, "show_id", show.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+show.Title)
|
||||
continue
|
||||
}
|
||||
tmdbId, err := strconv.Atoi(tmdbIdStr)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Show to import does not have a parseable (to int) tmdb id.", "show_name", show.Title, "tmdb_id_str", tmdbIdStr, "show_id", show.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "show could not be imported (tmdbId was not parseable): "+show.Title)
|
||||
continue
|
||||
}
|
||||
|
||||
lastViewedAt := time.Unix(show.LastViewedAt, 0)
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: SHOW,
|
||||
Rating: int8(show.UserRating),
|
||||
WatchedDate: lastViewedAt,
|
||||
}, IMPORTED_WATCHED_PLEX)
|
||||
if err != nil {
|
||||
if err.Error() == "content already on watched list" {
|
||||
slog.Info("plexSyncWatched: unique constraint hit. show must already be on watch list", "error", err)
|
||||
} else {
|
||||
slog.Error("plexSyncWatched: Failed to add show as watched", "error", err)
|
||||
addJobError(jobId, userId, "failed to add show "+show.Title)
|
||||
}
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
|
||||
if !lastViewedAt.IsZero() {
|
||||
_, err := addActivity(db, userId, ActivityAddRequest{
|
||||
WatchedID: w.ID,
|
||||
Type: IMPORTED_ADDED_WATCHED_PLEX,
|
||||
CustomDate: &lastViewedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to add dateswatched activity.", "movie_name", show.Title,
|
||||
"movie_id", show.GUID, "user_id", userId, "date", lastViewedAt, "unparsed_date", show.LastViewedAt, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import watched seasons for this serie
|
||||
seriesSeasons, err := getPlexLibraryItemSeasons(userPlexLocalAuth, show.RatingKey)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch series seasons.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series seasons could not be imported (request failed): "+show.Title)
|
||||
} else if len(seriesSeasons.MediaContainer.Metadata) <= 0 {
|
||||
slog.Info("plexSyncWatched: Series has no seasons.", "series_name", show.Title, "serie_ids", show.GUID, "user_id", userId)
|
||||
} else {
|
||||
for _, vs := range seriesSeasons.MediaContainer.Metadata {
|
||||
slog.Debug("plexSyncWatched: Processing a season.", "full_item", vs, "user_id", userId)
|
||||
if vs.ViewedLeafCount != vs.LeafCount {
|
||||
slog.Debug("plexSyncWatched: Skipping import of unplayed season.", "series_name", show.Title, "season_num", vs.Index, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "syncing "+show.Title+" season "+strconv.Itoa(vs.Index))
|
||||
var seasonLastViewedAt time.Time
|
||||
if vs.LastViewedAt != 0 {
|
||||
seasonLastViewedAt = time.Unix(vs.LastViewedAt, 0)
|
||||
}
|
||||
_, err = addWatchedSeason(db, userId, WatchedSeasonAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: vs.Index,
|
||||
Status: FINISHED,
|
||||
addActivity: SEASON_ADDED_PLEX,
|
||||
addActivityDate: seasonLastViewedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch series seasons.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series season could not be imported (addWatchedSeason request failed): "+show.Title+" season "+strconv.Itoa(vs.Index))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import watched episodes for this serie
|
||||
seriesEpisodes, err := getPlexLibraryItemEpisodes(userPlexLocalAuth, show.RatingKey)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch series episodes.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series episodes could not be imported (request failed): "+show.Title)
|
||||
} else if len(seriesEpisodes.MediaContainer.Metadata) <= 0 {
|
||||
slog.Info("plexSyncWatched: Series has no episodes.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId)
|
||||
} else {
|
||||
for _, vs := range seriesEpisodes.MediaContainer.Metadata {
|
||||
slog.Debug("plexSyncWatched: Processing an episode.", "full_item", vs, "user_id", userId)
|
||||
if vs.ViewCount <= 0 {
|
||||
slog.Debug("plexSyncWatched: Skipping import of unplayed episode.", "series_name", show.Title, "season_num", vs.ParentIndex, "episode_num", vs.Index, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "syncing "+show.Title+" season "+strconv.Itoa(vs.ParentIndex)+" episode "+strconv.Itoa(vs.Index))
|
||||
var episodeLastViewedAt time.Time
|
||||
if vs.LastViewedAt != 0 {
|
||||
episodeLastViewedAt = time.Unix(vs.LastViewedAt, 0)
|
||||
}
|
||||
_, err = addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: vs.ParentIndex,
|
||||
EpisodeNumber: vs.Index,
|
||||
Status: FINISHED,
|
||||
addActivity: EPISODE_ADDED_PLEX,
|
||||
addActivityDate: episodeLastViewedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to import series episode.", "series_name", show.Title, "season_num", vs.ParentIndex, "episode_num", vs.Index, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series episode could not be imported (addWatchedEpisode request failed): "+show.Title+" "+vs.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
updateJobStatus(jobId, userId, JOB_DONE)
|
||||
}
|
||||
|
||||
func plexSyncWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
userPlexLocalAuth string,
|
||||
) (PlexSyncResponse, error) {
|
||||
jobId, err := addJob("plex_sync", userId)
|
||||
if err != nil {
|
||||
slog.Error("startPlexSync: Failed to create a job", "error", err)
|
||||
return PlexSyncResponse{}, errors.New("failed to create job")
|
||||
}
|
||||
|
||||
updateJobStatus(jobId, userId, JOB_RUNNING)
|
||||
|
||||
go startPlexSync(
|
||||
db,
|
||||
jobId,
|
||||
userId,
|
||||
userPlexLocalAuth,
|
||||
)
|
||||
|
||||
return PlexSyncResponse{JobId: jobId}, nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -9,9 +10,56 @@ import (
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
Joined time.Time `json:"joined"`
|
||||
ShowsWatched int32 `json:"showsWatched"`
|
||||
MoviesWatched int32 `json:"moviesWatched"`
|
||||
Joined time.Time `json:"joined"`
|
||||
ShowsWatched int32 `json:"showsWatched"`
|
||||
MoviesWatched int32 `json:"moviesWatched"`
|
||||
MoviesWatchedRuntime uint32 `json:"moviesWatchedRuntime"`
|
||||
ShowsWatchedRuntime uint32 `json:"showsWatchedRuntime"`
|
||||
}
|
||||
|
||||
// Check if content has been previsouly watched by looking for related activity.
|
||||
func hasBeenPreviouslyWatched(a *[]Activity) bool {
|
||||
wp := false
|
||||
var relatedActivity []Activity
|
||||
for _, v := range *a {
|
||||
if v.Type == ADDED_WATCHED ||
|
||||
v.Type == IMPORTED_ADDED_WATCHED ||
|
||||
v.Type == IMPORTED_WATCHED ||
|
||||
v.Type == STATUS_CHANGED {
|
||||
relatedActivity = append(relatedActivity, v)
|
||||
}
|
||||
}
|
||||
if len(relatedActivity) <= 0 {
|
||||
return false
|
||||
}
|
||||
for _, ra := range relatedActivity {
|
||||
if ra.Type == IMPORTED_ADDED_WATCHED {
|
||||
wp = true
|
||||
break
|
||||
} else if ra.Type == ADDED_WATCHED || ra.Type == IMPORTED_WATCHED {
|
||||
if ra.Data == "" {
|
||||
continue
|
||||
}
|
||||
var v map[string]any
|
||||
err := json.Unmarshal([]byte(ra.Data), &v)
|
||||
if err != nil {
|
||||
slog.Error("Checking ADDED_WATCHED or IMPORTED_WATCHED.. failed to parse json data", "error", err)
|
||||
continue
|
||||
}
|
||||
if status, ok := v["status"]; ok {
|
||||
if status == "FINISHED" {
|
||||
wp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if ra.Type == STATUS_CHANGED {
|
||||
if ra.Data == "FINISHED" {
|
||||
wp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return wp
|
||||
}
|
||||
|
||||
// Gets any data required for profile page
|
||||
@@ -23,24 +71,54 @@ func getProfile(db *gorm.DB, userId uint) (Profile, error) {
|
||||
return Profile{}, errors.New("failed to get profile")
|
||||
}
|
||||
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 {
|
||||
slog.Error("Profile: Failed to get watched for processing:", "error", res.Error.Error())
|
||||
return Profile{}, errors.New("failed to get watched for processing")
|
||||
}
|
||||
var (
|
||||
showsWatched int32
|
||||
moviesWatched int32
|
||||
showsWatched int32
|
||||
moviesWatched int32
|
||||
moviesWatchedRuntime uint32
|
||||
showsWatchedRuntime uint32
|
||||
)
|
||||
for _, w := range *watched {
|
||||
isFinished := false
|
||||
if w.Status == FINISHED {
|
||||
if w.Content.Type == SHOW {
|
||||
isFinished = true
|
||||
} else if *user.IncludePreviouslyWatched && hasBeenPreviouslyWatched(&w.Activity) {
|
||||
// If status is not finished and user has IncludePreviouslyWatched enabled,
|
||||
// then we can also check if content hasBeenPreviouslyWatched.
|
||||
isFinished = true
|
||||
}
|
||||
if isFinished {
|
||||
if w.Content == nil {
|
||||
continue
|
||||
}
|
||||
c := *w.Content
|
||||
if c.Type == SHOW {
|
||||
showsWatched++
|
||||
} else if w.Content.Type == MOVIE {
|
||||
// This aint a science, just a very inaccurate guesstimate.
|
||||
if c.NumberOfEpisodes != 0 {
|
||||
var showRuntime uint32 = 30
|
||||
if c.Runtime != 0 {
|
||||
showRuntime = c.Runtime
|
||||
}
|
||||
showsWatchedRuntime += showRuntime * c.NumberOfEpisodes
|
||||
slog.Debug("calcualted", "show", c.Title, "runti", showRuntime*c.NumberOfEpisodes)
|
||||
}
|
||||
} else if c.Type == MOVIE {
|
||||
moviesWatched++
|
||||
moviesWatchedRuntime += c.Runtime
|
||||
}
|
||||
}
|
||||
}
|
||||
profile := Profile{Joined: user.CreatedAt, ShowsWatched: showsWatched, MoviesWatched: moviesWatched}
|
||||
profile := Profile{
|
||||
Joined: user.CreatedAt,
|
||||
ShowsWatched: showsWatched,
|
||||
MoviesWatched: moviesWatched,
|
||||
MoviesWatchedRuntime: moviesWatchedRuntime,
|
||||
ShowsWatchedRuntime: showsWatchedRuntime,
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/gin-contrib/cache/persistence"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/arr"
|
||||
"github.com/sbondCo/Watcharr/game"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -17,6 +18,10 @@ type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type ValueRequest struct {
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
type KeyValueRequest struct {
|
||||
Key string `json:"key"`
|
||||
Value any `json:"value"`
|
||||
@@ -25,12 +30,14 @@ type KeyValueRequest struct {
|
||||
type BaseRouter struct {
|
||||
db *gorm.DB
|
||||
rg *gin.RouterGroup
|
||||
ms *persistence.InMemoryStore
|
||||
}
|
||||
|
||||
func newBaseRouter(db *gorm.DB, rg *gin.RouterGroup) *BaseRouter {
|
||||
return &BaseRouter{
|
||||
db: db,
|
||||
rg: rg,
|
||||
ms: persistence.NewInMemoryStore(time.Hour * 24),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,10 +77,9 @@ func (b *BaseRouter) addSetupRoutes() {
|
||||
func (b *BaseRouter) addContentRoutes() {
|
||||
content := b.rg.Group("/content").Use(AuthRequired(nil))
|
||||
exp := time.Hour * 24
|
||||
store := persistence.NewInMemoryStore(exp)
|
||||
|
||||
// Search for content
|
||||
content.GET("/:query", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
// println(c.Param("query"))
|
||||
if c.Param("query") == "" {
|
||||
c.Status(400)
|
||||
@@ -88,12 +94,12 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get movie details (for movie page)
|
||||
content.Use(WhereaboutsRequired()).GET("/movie/:id", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.Use(WhereaboutsRequired()).GET("/movie/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := movieDetails(c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar"})
|
||||
content, err := movieDetails(b.db, c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -102,7 +108,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get movie cast
|
||||
content.GET("/movie/:id/credits", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/movie/:id/credits", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -116,12 +122,12 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get tv details (for tv page)
|
||||
content.Use(WhereaboutsRequired()).GET("/tv/:id", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.Use(WhereaboutsRequired()).GET("/tv/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := tvDetails(c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar,external_ids,keywords"})
|
||||
content, err := tvDetails(b.db, c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar,external_ids,keywords"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -130,7 +136,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get tv cast
|
||||
content.GET("/tv/:id/credits", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/tv/:id/credits", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -144,7 +150,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get season details
|
||||
content.GET("/tv/:id/season/:num", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/tv/:id/season/:num", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" || c.Param("num") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -158,7 +164,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get person details
|
||||
content.GET("/person/:id", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/person/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -172,7 +178,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get person credits
|
||||
content.GET("/person/:id/credits", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/person/:id/credits", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -186,7 +192,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Discover movies
|
||||
content.GET("/discover/movies", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/discover/movies", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content, err := discoverMovies()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -196,7 +202,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Discover shows
|
||||
content.GET("/discover/tv", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/discover/tv", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content, err := discoverTv()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -206,7 +212,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get all trending (movies, tv, people)
|
||||
content.GET("/trending", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/trending", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content, err := allTrending()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -216,7 +222,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Upcoming Movies
|
||||
content.GET("/upcoming/movies", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/upcoming/movies", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content, err := upcomingMovies()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -226,7 +232,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Upcoming Tv
|
||||
content.GET("/upcoming/tv", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/upcoming/tv", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content, err := upcomingTv()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -236,6 +242,90 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addGameRoutes() {
|
||||
gamer := b.rg.Group("/game").Use(AuthRequired(nil))
|
||||
exp := time.Hour * 24
|
||||
|
||||
igdb := &Config.TWITCH
|
||||
igdb.OnTokenRefreshed(func() {
|
||||
// Save new token to config when we refresh it.
|
||||
slog.Debug("GameRoutes: token refreshed.. saving to config.")
|
||||
if err := writeConfig(); err != nil {
|
||||
slog.Error("GameRoutes: failed to save refreshed token to config.", "error", err)
|
||||
}
|
||||
})
|
||||
err := igdb.Init()
|
||||
// Save cfg if init succeeded, this will save our access token
|
||||
if err == nil {
|
||||
slog.Error("GameRoutes: Twitch init failed!", "error", err)
|
||||
}
|
||||
|
||||
// Search for games
|
||||
gamer.GET("/search/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("query") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
games, err := igdb.Search(c.Param("query"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, games)
|
||||
}))
|
||||
|
||||
// Game details for game page
|
||||
gamer.GET("/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := igdb.GameDetails(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Add game to played(watched) list
|
||||
gamer.POST("/played", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar PlayedAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := addPlayed(b.db, igdb, userId, ar, ADDED_WATCHED)
|
||||
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()})
|
||||
})
|
||||
|
||||
// IMPORTANT: Routes below only for admins!
|
||||
gamer.Use(AuthRequired(b.db), AdminRequired())
|
||||
{
|
||||
gamer.POST("/config", func(c *gin.Context) {
|
||||
var ar game.IGDB
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
err := saveTwitchConfig(ar)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
igdb = &Config.TWITCH
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addWatchedRoutes() {
|
||||
watched := b.rg.Group("/watched").Use(AuthRequired(nil))
|
||||
|
||||
@@ -320,7 +410,7 @@ func (b *BaseRouter) addWatchedRoutes() {
|
||||
var ar WatchedSeasonAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := addWatchedSeason(b.db, userId, ar, ADDED_WATCHED)
|
||||
response, err := addWatchedSeason(b.db, userId, ar)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -345,6 +435,37 @@ func (b *BaseRouter) addWatchedRoutes() {
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
watched.POST("/episode", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar WatchedEpisodeAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := addWatchedEpisodes(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()})
|
||||
})
|
||||
|
||||
watched.DELETE("/episode/:id", func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := rmWatchedEpisode(b.db, userId, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addActivityRoutes() {
|
||||
@@ -380,6 +501,43 @@ func (b *BaseRouter) addActivityRoutes() {
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
activity.PUT(":id", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
var activityUpdateRequest ActivityUpdateRequest
|
||||
err = c.ShouldBindJSON(&activityUpdateRequest)
|
||||
if err == nil {
|
||||
err = updateActivity(b.db, userId, uint(id), activityUpdateRequest)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
activity.DELETE(":id", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
slog.Error("Could not process activity id when attempting a deletion", "error", err.Error(), "id", c.Param("id"))
|
||||
return
|
||||
}
|
||||
err = deleteActivity(b.db, userId, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addAuthRoutes() {
|
||||
@@ -415,6 +573,21 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
c.Status(400)
|
||||
})
|
||||
|
||||
// Plex login
|
||||
auth.POST("/plex", func(c *gin.Context) {
|
||||
var plexRequest PlexLoginRequest
|
||||
if c.ShouldBindJSON(&plexRequest) == nil {
|
||||
response, err := loginPlex(&plexRequest, 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 UserRegisterRequest
|
||||
@@ -436,6 +609,9 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
if Config.JELLYFIN_HOST != "" {
|
||||
availableAuthProviders = append(availableAuthProviders, "jellyfin")
|
||||
}
|
||||
if Config.PLEX_HOST != "" && Config.PLEX_MACHINE_ID != "" {
|
||||
availableAuthProviders = append(availableAuthProviders, "plex")
|
||||
}
|
||||
c.JSON(http.StatusOK, &AvailableAuthProvidersResponse{
|
||||
AvailableAuthProviders: availableAuthProviders,
|
||||
SignupEnabled: Config.SIGNUP_ENABLED,
|
||||
@@ -443,33 +619,54 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
})
|
||||
})
|
||||
|
||||
// Request admin token
|
||||
auth.Use(AuthRequired(nil)).GET("/admin_token", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
token, err := createOneUseToken(b.db, TOKENTYPE_ADMIN, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
slog.Info("Admin token generated. Type this token into the web ui to gain admin access on your account.", "token", token, "generated_for", userId)
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
|
||||
// Use admin token
|
||||
auth.Use(AuthRequired(nil)).POST("/admin_token", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var atr UseAdminTokenRequest
|
||||
if c.ShouldBindJSON(&atr) == nil {
|
||||
err := useAdminToken(&atr, b.db, userId)
|
||||
// IMPORTANT: Routes below here must be authenticated.
|
||||
auth.Use(AuthRequired(nil))
|
||||
{
|
||||
// Request admin token
|
||||
auth.GET("/admin_token", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
token, err := createOneUseToken(b.db, TOKENTYPE_ADMIN, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
slog.Info("Admin token generated. Type this token into the web ui to gain admin access on your account.", "token", token, "generated_for", userId)
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Status(400)
|
||||
})
|
||||
})
|
||||
|
||||
// Use admin token
|
||||
auth.POST("/admin_token", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var atr UseAdminTokenRequest
|
||||
if c.ShouldBindJSON(&atr) == nil {
|
||||
err := useAdminToken(&atr, b.db, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Status(400)
|
||||
})
|
||||
|
||||
// Change password
|
||||
auth.POST("/change_password", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var pwds UserPasswordUpdateRequest
|
||||
err := c.ShouldBindJSON(&pwds)
|
||||
if err == nil {
|
||||
err := userChangePassword(b.db, pwds, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addProfileRoutes() {
|
||||
@@ -488,7 +685,7 @@ func (b *BaseRouter) addProfileRoutes() {
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addJellyfinRoutes() {
|
||||
jf := b.rg.Group("/jellyfin").Use(AuthRequired(b.db))
|
||||
jf := b.rg.Group("/jellyfin").Use(AuthRequired(b.db), JellyfinAccessRequired())
|
||||
|
||||
// Check if jf has item
|
||||
jf.GET("/:type/:name/:tmdbId", func(c *gin.Context) {
|
||||
@@ -504,6 +701,37 @@ func (b *BaseRouter) addJellyfinRoutes() {
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Sync users jellyfin watched items to watchlist
|
||||
jf.GET("/sync", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
userType := c.MustGet("userType").(UserType)
|
||||
username := c.MustGet("username").(string)
|
||||
userThirdPartyId := c.MustGet("userThirdPartyId").(string)
|
||||
userThirdPartyAuth := c.MustGet("userThirdPartyAuth").(string)
|
||||
response, err := jellyfinSyncWatched(b.db, userId, userType, username, userThirdPartyId, userThirdPartyAuth)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addPlexRoutes() {
|
||||
plex := b.rg.Group("/plex").Use(AuthRequired(b.db), PlexAccessRequired(b.db))
|
||||
|
||||
// Sync users plex watched items to watchlist
|
||||
plex.GET("/sync", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
userPlexLocalAuth := c.MustGet("plexLocalAuthToken").(string)
|
||||
response, err := plexSyncWatched(b.db, userId, userPlexLocalAuth)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addUserRoutes() {
|
||||
@@ -558,6 +786,114 @@ func (b *BaseRouter) addUserRoutes() {
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Search users
|
||||
u.GET("/public/:pubUserId/:pubUsername", func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("pubUserId"))
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := getUserPublicInfo(b.db, uint(id), c.Param("pubUsername"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Update bio
|
||||
u.POST("/bio", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var br UserBioUpdateRequest
|
||||
err := c.ShouldBindJSON(&br)
|
||||
if err == nil {
|
||||
err := userUpdateBio(b.db, userId, br.NewBio)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// Upload avatar
|
||||
u.POST("/avatar", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := uploadUserAvatar(c, b.db, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addFollowRoutes() {
|
||||
f := b.rg.Group("/follow").Use(AuthRequired(b.db))
|
||||
|
||||
// Get users follows // TODO extend to support optionally passing user id as route param, default to current user
|
||||
f.GET("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := getFollows(b.db, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Follow a user
|
||||
f.POST("/:toFollowId", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
toFollowId, err := strconv.ParseUint(c.Param("toFollowId"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("failed to convert toFollowId param to uint", "toFollowId", toFollowId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := followUser(b.db, userId, uint(toFollowId))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Unfollow a user
|
||||
f.DELETE("/:toUnfollowId", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
toUnfollowId, err := strconv.ParseUint(c.Param("toUnfollowId"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("failed to convert toUnfollowId param to uint", "toUnfollowId", toUnfollowId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := unfollowUser(b.db, userId, uint(toUnfollowId))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Get follows thoughts on content
|
||||
f.GET("/thoughts/:type/:tmdbId", func(c *gin.Context) {
|
||||
t := c.Param("type")
|
||||
if t != "movie" && t != "tv" && t != "game" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "only movie, tv or game types are supported"})
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := getFollowsThoughts(b.db, userId, t, c.Param("tmdbId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addImportRoutes() {
|
||||
@@ -604,6 +940,27 @@ func (b *BaseRouter) addServerRoutes() {
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// Update plex host config
|
||||
server.POST("/config/plex_host", func(c *gin.Context) {
|
||||
var ur ValueRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
resp, err := updateConfigPlexHost(ur.Value.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// Get server stats
|
||||
server.GET("/stats", cache.CachePage(b.ms, time.Minute*5, func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, getServerStats(b.db))
|
||||
}))
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addFeatureRoutes() {
|
||||
@@ -821,3 +1178,17 @@ func (b *BaseRouter) addRadarrRoutes() {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addJobRoutes() {
|
||||
job := b.rg.Group("/job").Use(AuthRequired(nil))
|
||||
|
||||
job.GET("/:id", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := getJob(c.Param("id"), userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, *response)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ import (
|
||||
|
||||
// Setup recurring tasks (eg cleanup every x mins)
|
||||
func setupTasks(db *gorm.DB) {
|
||||
go setupMinutelyTasks(db)
|
||||
go setupDailyTasks(db)
|
||||
}
|
||||
|
||||
func setupMinutelyTasks(db *gorm.DB) {
|
||||
taskRunInterval := 1 * time.Minute
|
||||
ticker := time.NewTicker(taskRunInterval)
|
||||
defer ticker.Stop()
|
||||
@@ -18,3 +23,13 @@ func setupTasks(db *gorm.DB) {
|
||||
cleanupTokens(db)
|
||||
}
|
||||
}
|
||||
|
||||
func setupDailyTasks(db *gorm.DB) {
|
||||
taskRunInterval := 24 * time.Hour
|
||||
ticker := time.NewTicker(taskRunInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
cleanupImages(db)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -11,6 +18,9 @@ import (
|
||||
type PublicUser struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
AvatarID uint `json:"-"`
|
||||
Avatar Image `json:"avatar"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
}
|
||||
|
||||
// Private user details, for returning users details to themselves
|
||||
@@ -18,6 +28,13 @@ type PrivateUser struct {
|
||||
Username string `json:"username"`
|
||||
Type UserType `json:"type"`
|
||||
Permissions int `json:"permissions"`
|
||||
AvatarID uint `json:"-"`
|
||||
Avatar Image `json:"avatar"`
|
||||
Bio string `json:"bio"`
|
||||
}
|
||||
|
||||
type UserBioUpdateRequest struct {
|
||||
NewBio string `json:"newBio" binding:"max=128"`
|
||||
}
|
||||
|
||||
// Update user settings
|
||||
@@ -26,7 +43,7 @@ func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error)
|
||||
user := new(User)
|
||||
res := db.Where("id = ?", userId).Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("user update failed", "user_id", userId, "error", "failed to retrieve user from database")
|
||||
slog.Error("user update failed", "user_id", userId, "error", res.Error)
|
||||
return UserSettings{}, errors.New("failed to retrieve user")
|
||||
}
|
||||
if ur.HideSpoilers != nil {
|
||||
@@ -35,8 +52,19 @@ func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error)
|
||||
if ur.Private != nil {
|
||||
user.Private = ur.Private
|
||||
}
|
||||
if ur.PrivateThoughts != nil {
|
||||
user.PrivateThoughts = ur.PrivateThoughts
|
||||
}
|
||||
if ur.IncludePreviouslyWatched != nil {
|
||||
user.IncludePreviouslyWatched = ur.IncludePreviouslyWatched
|
||||
}
|
||||
db.Save(&user)
|
||||
return UserSettings{Private: user.Private, HideSpoilers: user.HideSpoilers}, nil
|
||||
return UserSettings{
|
||||
Private: user.Private,
|
||||
PrivateThoughts: user.PrivateThoughts,
|
||||
HideSpoilers: user.HideSpoilers,
|
||||
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
|
||||
@@ -44,10 +72,15 @@ func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
|
||||
user := new(User)
|
||||
res := db.Where("id = ?", userId).Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("user get failed", "user_id", userId, "error", "failed to retrieve user from database")
|
||||
slog.Error("user get failed", "user_id", userId, "error", res.Error)
|
||||
return UserSettings{}, errors.New("failed to retrieve user")
|
||||
}
|
||||
return UserSettings{Private: user.Private, HideSpoilers: user.HideSpoilers}, nil
|
||||
return UserSettings{
|
||||
Private: user.Private,
|
||||
PrivateThoughts: user.PrivateThoughts,
|
||||
HideSpoilers: user.HideSpoilers,
|
||||
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func userSearch(db *gorm.DB, currentUsersId uint, q string) ([]PublicUser, error) {
|
||||
@@ -55,7 +88,7 @@ func userSearch(db *gorm.DB, currentUsersId uint, q string) ([]PublicUser, error
|
||||
users := new([]PublicUser)
|
||||
res := db.Where("private = 0 AND username LIKE ? AND id != ?", "%"+q+"%", currentUsersId).Table("users").Find(&users)
|
||||
if res.Error != nil {
|
||||
slog.Error("user search failed", "error", "failed to query database")
|
||||
slog.Error("user search failed", "error", res.Error)
|
||||
return []PublicUser{}, errors.New("failed to find users")
|
||||
}
|
||||
return *users, nil
|
||||
@@ -64,10 +97,86 @@ func userSearch(db *gorm.DB, currentUsersId uint, q string) ([]PublicUser, error
|
||||
func getUserInfo(db *gorm.DB, currentUsersId uint) (PrivateUser, error) {
|
||||
slog.Debug("user get info request running")
|
||||
user := new(PrivateUser)
|
||||
res := db.Where("id = ?", currentUsersId).Table("users").Take(&user)
|
||||
res := db.Where("id = ?", currentUsersId).Table("users").Preload("Avatar").Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("user get info failed", "error", res.Error)
|
||||
return PrivateUser{}, errors.New("failed to find current user")
|
||||
}
|
||||
return *user, nil
|
||||
}
|
||||
|
||||
// For getting a public user's info, when viewing their list for example
|
||||
func getUserPublicInfo(db *gorm.DB, userId uint, username string) (PublicUser, error) {
|
||||
slog.Debug("user get info request running")
|
||||
user := new(PublicUser)
|
||||
res := db.Where("private = 0 AND id = ? AND username = ?", userId, username).Table("users").Preload("Avatar").Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("public user get info failed", "error", res.Error)
|
||||
return PublicUser{}, errors.New("failed to find user")
|
||||
}
|
||||
return *user, nil
|
||||
}
|
||||
|
||||
func userUpdateBio(db *gorm.DB, userId uint, newBio string) error {
|
||||
slog.Debug("userUpdateBio request running", "user_id", userId, "newBio", newBio)
|
||||
if res := db.Model(&User{}).Where("id = ?", userId).Update("bio", newBio); res.Error != nil {
|
||||
slog.Error("userUpdateBio failed", "user_id", userId, "error", res.Error)
|
||||
return errors.New("failed to update bio")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uploadUserAvatar(c *gin.Context, db *gorm.DB, userId uint) (Image, error) {
|
||||
file, err := c.FormFile("avatar")
|
||||
if err != nil {
|
||||
slog.Error("failed to get file", "error", err)
|
||||
return Image{}, errors.New("no file found")
|
||||
}
|
||||
|
||||
slog.Debug("an avatar is being uploaded", "name", file.Filename)
|
||||
|
||||
f, _ := file.Open()
|
||||
if err := isValidImageType(f); err != nil {
|
||||
return Image{}, errors.New("invalid image type")
|
||||
}
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
log.Fatal(err) // TODO nu le fatal
|
||||
}
|
||||
hs := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
slog.Debug("image hash calculated", "hash", hs, "first_letter", hs[0:1])
|
||||
|
||||
// Upload the file to specific dst.
|
||||
outp := path.Join("img/up/", hs[0:1], hs+filepath.Ext(file.Filename))
|
||||
c.SaveUploadedFile(file, path.Join(DataPath, outp))
|
||||
|
||||
_, err = f.Seek(0, 0)
|
||||
if err != nil {
|
||||
slog.Error("uploadUserAvatar seeking back to start of image failed", "error", err)
|
||||
}
|
||||
|
||||
// No need to remove old image, the daily cleanup task will handle removing unused ones.
|
||||
var img Image
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
// Insert avatar into db
|
||||
img, err = insertImage(db, hs, outp, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if img.ID == 0 {
|
||||
return errors.New("image has no id")
|
||||
}
|
||||
// Update users avatar to newly inserted
|
||||
if err := tx.Where("id = ?", userId).Updates(&User{AvatarID: img.ID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// commit transaction if no errors
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("uploadUserAvatar failed!", "error", err)
|
||||
return Image{}, errors.New("uploadUserAvatar transaction failed")
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
@@ -63,12 +64,24 @@ func main() {
|
||||
isProd = false
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("./data/watcharr.db"), &gorm.Config{})
|
||||
db, err := gorm.Open(sqlite.Open("./data/watcharr.db"), &gorm.Config{TranslateError: true})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
|
||||
err = db.AutoMigrate(&User{}, &Content{}, &Watched{}, &WatchedSeason{}, &Activity{}, &Token{})
|
||||
err = db.AutoMigrate(
|
||||
&User{},
|
||||
&UserServices{},
|
||||
&Content{},
|
||||
&Watched{},
|
||||
&WatchedSeason{},
|
||||
&WatchedEpisode{},
|
||||
&Activity{},
|
||||
&Token{},
|
||||
&Follow{},
|
||||
&Image{},
|
||||
&Game{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to auto migrate database:", err)
|
||||
}
|
||||
@@ -114,17 +127,21 @@ func main() {
|
||||
}
|
||||
br.addAuthRoutes()
|
||||
br.addContentRoutes()
|
||||
br.addGameRoutes()
|
||||
br.addWatchedRoutes()
|
||||
br.addActivityRoutes()
|
||||
br.addProfileRoutes()
|
||||
br.addJellyfinRoutes()
|
||||
br.addPlexRoutes()
|
||||
br.addUserRoutes()
|
||||
br.addFollowRoutes()
|
||||
br.addImportRoutes()
|
||||
br.addServerRoutes()
|
||||
br.addFeatureRoutes()
|
||||
br.addSonarrRoutes()
|
||||
br.addRadarrRoutes()
|
||||
br.rg.Static("/img", "./data/img")
|
||||
br.addJobRoutes()
|
||||
br.rg.Static("/img", path.Join(DataPath, "img"))
|
||||
|
||||
go setupTasks(db)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -28,21 +27,28 @@ const (
|
||||
|
||||
type Watched struct {
|
||||
GormModel
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx"`
|
||||
ContentID int `json:"-" gorm:"uniqueIndex:usernctnidx"`
|
||||
Content Content `json:"content"`
|
||||
Activity []Activity `json:"activity"`
|
||||
WatchedSeasons []WatchedSeason `json:"watchedSeasons,omitempty"` // For shows
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx;uniqueIndex:userngamidx"`
|
||||
ContentID *int `json:"-" gorm:"uniqueIndex:usernctnidx"`
|
||||
Content *Content `json:"content,omitempty"`
|
||||
GameID *int `json:"-" gorm:"uniqueIndex:userngamidx"`
|
||||
Game *Game `json:"game,omitempty"`
|
||||
Activity []Activity `json:"activity"`
|
||||
WatchedSeasons []WatchedSeason `json:"watchedSeasons,omitempty"` // For shows
|
||||
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes,omitempty"` // For shows
|
||||
}
|
||||
|
||||
type WatchedAddRequest struct {
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
ContentID int `json:"contentId" binding:"required"`
|
||||
ContentType ContentType `json:"contentType" binding:"required,oneof=movie tv"`
|
||||
// Pass a watched date and we will set the CreatedAt (and initial UpdatedAt)
|
||||
// properties for this watched entry to this specific date.
|
||||
WatchedDate time.Time `json:"watchedDate,omitempty"`
|
||||
}
|
||||
|
||||
type WatchedUpdateRequest struct {
|
||||
@@ -62,7 +68,7 @@ type WatchedRemoveResponse struct {
|
||||
|
||||
func getWatched(db *gorm.DB, userId uint) []Watched {
|
||||
watched := new([]Watched)
|
||||
res := db.Model(&Watched{}).Preload("Content").Preload("Activity").Preload("WatchedSeasons").Where("user_id = ?", userId).Find(&watched)
|
||||
res := db.Model(&Watched{}).Preload("Content").Preload("Game").Preload("Game.Poster").Preload("Activity").Preload("WatchedSeasons").Preload("WatchedEpisodes").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
@@ -88,7 +94,7 @@ func getPublicWatched(db *gorm.DB, userId uint, username string) ([]Watched, err
|
||||
}
|
||||
// Now we know the user is public, return their list
|
||||
watched := new([]Watched)
|
||||
res = db.Model(&Watched{}).Preload("Content").Where("user_id = ?", userId).Find(&watched)
|
||||
res = db.Model(&Watched{}).Preload("Content").Preload("Game").Preload("Game.Poster").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
@@ -99,7 +105,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType)
|
||||
slog.Debug("Adding watched item", "userId", userId, "contentType", ar.ContentType, "contentId", ar.ContentID)
|
||||
|
||||
var content Content
|
||||
db.Where("tmdb_id = ?", ar.ContentID).Find(&content)
|
||||
db.Where("type = ? AND tmdb_id = ?", ar.ContentType, ar.ContentID).Find(&content)
|
||||
|
||||
// Create content if not found from our db
|
||||
if content == (Content{}) {
|
||||
@@ -111,110 +117,29 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType)
|
||||
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)
|
||||
c := new(TMDBMovieDetails)
|
||||
err := json.Unmarshal([]byte(resp), &c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal movie details", "error", 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)
|
||||
content, err = cacheContentMovie(db, *c, false)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse movie release date", "error", err)
|
||||
slog.Error("addWatched failed to cache movie content", "content_id", ar.ContentID, "err", err)
|
||||
return Watched{}, errors.New("failed to cache content")
|
||||
}
|
||||
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)
|
||||
c := new(TMDBShowDetails)
|
||||
err := json.Unmarshal(resp, &c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal show details", "error", err)
|
||||
return Watched{}, errors.New("failed to process show details response")
|
||||
slog.Error("Failed to unmarshal tv details", "error", err)
|
||||
return Watched{}, errors.New("failed to process tv details response")
|
||||
}
|
||||
id = content.ID
|
||||
overview = content.Overview
|
||||
posterPath = content.PosterPath
|
||||
title = content.Name
|
||||
releaseDate, err = time.Parse(dateFormat, content.FirstAirDate)
|
||||
content, err = cacheContentTv(db, *c, false)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse tv release date", "error", 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
|
||||
slog.Info("Saving content to db", "id", id, "title", title)
|
||||
if id == 0 || title == "" {
|
||||
slog.Error("addWatched, returned content missing id or title!", "id", id, "title", 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") {
|
||||
slog.Error("Error creating content in database", "error", 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 {
|
||||
slog.Error("Failed to download content image!", "error", err.Error())
|
||||
slog.Error("addWatched failed to cache tv content", "content_id", ar.ContentID, "err", err)
|
||||
return Watched{}, errors.New("failed to cache content")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,23 +149,39 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType)
|
||||
}
|
||||
// Create watched entry in db
|
||||
if ar.Status == "" {
|
||||
ar.Status = WATCHING
|
||||
// Set default status for when content is added by
|
||||
// rating it instead of giving status first.
|
||||
if ar.ContentType == "movie" {
|
||||
ar.Status = FINISHED
|
||||
} else {
|
||||
ar.Status = WATCHING
|
||||
}
|
||||
}
|
||||
watched := Watched{Status: ar.Status, Rating: ar.Rating, UserID: userId, ContentID: &content.ID}
|
||||
if ar.Thoughts != "" {
|
||||
watched.Thoughts = ar.Thoughts
|
||||
}
|
||||
// If custom WatchedDate passed, set CreatedAt and UpdatedAt fields to it.
|
||||
if !ar.WatchedDate.IsZero() {
|
||||
slog.Debug("Adding watched item: The provided WatchedDate is valid.", "watched_date", ar.WatchedDate, "userId", userId, "contentType", ar.ContentType, "contentId", ar.ContentID)
|
||||
watched.CreatedAt = ar.WatchedDate
|
||||
watched.UpdatedAt = ar.WatchedDate
|
||||
}
|
||||
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") {
|
||||
if res.Error == gorm.ErrDuplicatedKey {
|
||||
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")
|
||||
return watched, errors.New("content already on watched list")
|
||||
} else {
|
||||
slog.Info("addWatched: Watched list item for this content 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
|
||||
watched.Thoughts = ar.Thoughts
|
||||
if res.Error != nil {
|
||||
slog.Error("addWatched: Failed to restore soft deleted watch list item", "error", res.Error)
|
||||
return Watched{}, errors.New("content already on watched list. errored removing soft delete timestamp")
|
||||
@@ -262,7 +203,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType)
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at, Data: string(activityJson)})
|
||||
}
|
||||
watched.Activity = append(watched.Activity, activity)
|
||||
watched.Content = content
|
||||
watched.Content = &content
|
||||
return watched, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// UniqueIndex applied between WatchedID, SeasonNum and EpisodeNum to avoid duplicates incase logic fails.
|
||||
//
|
||||
// Episodes on tmdb are only queried by season number + episode number, not possible via episode id,
|
||||
// since episodes can be removed and re-added. For this reason we store season and episodes nums instead
|
||||
// of just the episode id.
|
||||
type WatchedEpisode struct {
|
||||
GormModel
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
User User `json:"-"`
|
||||
WatchedID uint `json:"-" gorm:"uniqueIndex:we_watched_to_ens;not null"`
|
||||
SeasonNumber int `json:"seasonNumber" gorm:"uniqueIndex:we_watched_to_ens;not null"`
|
||||
EpisodeNumber int `json:"episodeNumber" gorm:"uniqueIndex:we_watched_to_ens;not null"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
}
|
||||
|
||||
type WatchedEpisodeAddRequest struct {
|
||||
WatchedID uint `json:"watchedId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
addActivity ActivityType `json:"-"`
|
||||
addActivityDate time.Time `json:"-"`
|
||||
}
|
||||
|
||||
type WatchedEpisodeAddResponse struct {
|
||||
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes"`
|
||||
AddedActivity Activity `json:"addedActivity"`
|
||||
}
|
||||
|
||||
// Add/edit a watched episode.
|
||||
func addWatchedEpisodes(db *gorm.DB, userId uint, ar WatchedEpisodeAddRequest) (WatchedEpisodeAddResponse, error) {
|
||||
slog.Debug("Adding watched episode item", "userId", userId, "watchedID", ar.WatchedID, "season", ar.SeasonNumber, "episode", ar.EpisodeNumber)
|
||||
// 1. Make sure watched item exists and it is the correct type (TV)
|
||||
var w Watched
|
||||
if resp := db.Where("id = ? AND user_id = ?", ar.WatchedID, userId).Preload("Content").Preload("WatchedEpisodes").Find(&w); resp.Error != nil {
|
||||
slog.Error("Failed when adding a watched episode", "error", "failed to get watched item from db")
|
||||
return WatchedEpisodeAddResponse{}, errors.New("failed when retrieving watched item")
|
||||
}
|
||||
if w.ID == 0 {
|
||||
slog.Error("Failed when adding a watched episode", "error", "watched item does not exist in db")
|
||||
return WatchedEpisodeAddResponse{}, errors.New("can't add a watched episode for a show that doesnt have a status itself")
|
||||
}
|
||||
if w.Content.Type != SHOW {
|
||||
return WatchedEpisodeAddResponse{}, errors.New("can't add watched episode for non show content")
|
||||
}
|
||||
found := false
|
||||
updated := false
|
||||
for i, we := range w.WatchedEpisodes {
|
||||
if we.SeasonNumber == ar.SeasonNumber && we.EpisodeNumber == ar.EpisodeNumber {
|
||||
slog.Debug("Existing watched episode item found, updating existing")
|
||||
found = true
|
||||
if ar.Status != "" && ar.Status != w.WatchedEpisodes[i].Status {
|
||||
w.WatchedEpisodes[i].Status = ar.Status
|
||||
updated = true
|
||||
}
|
||||
if ar.Rating != 0 && ar.Rating != w.WatchedEpisodes[i].Rating {
|
||||
w.WatchedEpisodes[i].Rating = ar.Rating
|
||||
updated = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
var addedActivity Activity
|
||||
if !found {
|
||||
slog.Debug("Existing watched episode not found, adding as new entry")
|
||||
w.WatchedEpisodes = append(w.WatchedEpisodes, WatchedEpisode{
|
||||
UserID: userId,
|
||||
WatchedID: ar.WatchedID,
|
||||
SeasonNumber: ar.SeasonNumber,
|
||||
EpisodeNumber: ar.EpisodeNumber,
|
||||
Status: ar.Status,
|
||||
Rating: ar.Rating,
|
||||
})
|
||||
}
|
||||
if resp := db.Save(&w.WatchedEpisodes); resp.Error != nil {
|
||||
slog.Debug("Failed to save watched episode item in db", "error", resp.Error)
|
||||
return WatchedEpisodeAddResponse{}, errors.New("failed to save")
|
||||
}
|
||||
// Add activity
|
||||
if found {
|
||||
// Only add change activity if we actually updated a value
|
||||
// (changing value to same value doesn't count).
|
||||
if updated {
|
||||
if ar.Status != "" {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "status": ar.Status})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: EPISODE_STATUS_CHANGED, Data: string(json)})
|
||||
}
|
||||
if ar.Rating != 0 {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "rating": ar.Rating})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: EPISODE_RATING_CHANGED, Data: string(json)})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "status": ar.Status, "rating": ar.Rating})
|
||||
act := ActivityAddRequest{WatchedID: w.ID, Type: EPISODE_ADDED, Data: string(json)}
|
||||
if ar.addActivity != "" {
|
||||
act.Type = ar.addActivity
|
||||
}
|
||||
if !ar.addActivityDate.IsZero() {
|
||||
act.CustomDate = &ar.addActivityDate
|
||||
}
|
||||
addedActivity, _ = addActivity(db, userId, act)
|
||||
}
|
||||
return WatchedEpisodeAddResponse{
|
||||
WatchedEpisodes: w.WatchedEpisodes,
|
||||
AddedActivity: addedActivity,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Remove a watched episode
|
||||
func rmWatchedEpisode(db *gorm.DB, userId uint, id uint) (Activity, error) {
|
||||
slog.Debug("rmWatchedSeason called", "user_id", userId, "id", id)
|
||||
var watchedEpisode WatchedEpisode
|
||||
resp := db.Clauses(clause.Returning{}).Model(&WatchedEpisode{}).Unscoped().Where("id = ? AND user_id = ?", id, userId).Delete(&watchedEpisode)
|
||||
if resp.Error != nil {
|
||||
slog.Error("Failed when removing a watched episode", "error", resp.Error)
|
||||
return Activity{}, errors.New("failed when removing watched episode")
|
||||
}
|
||||
if resp.RowsAffected == 0 {
|
||||
slog.Error("Failed when removing a watched episode", "error", "zero rows affected")
|
||||
return Activity{}, errors.New("wasn't removed from db.. may not exist")
|
||||
}
|
||||
slog.Debug("rmWatchedEpisode, deleted row", "row", watchedEpisode)
|
||||
if watchedEpisode.ID != 0 {
|
||||
json, _ := json.Marshal(map[string]interface{}{
|
||||
"season": watchedEpisode.SeasonNumber,
|
||||
"episode": watchedEpisode.EpisodeNumber,
|
||||
"status": watchedEpisode.Status,
|
||||
"rating": watchedEpisode.Rating,
|
||||
})
|
||||
addedActivity, _ := addActivity(db, userId, ActivityAddRequest{WatchedID: watchedEpisode.WatchedID, Type: EPISODE_REMOVED, Data: string(json)})
|
||||
return addedActivity, nil
|
||||
}
|
||||
return Activity{}, errors.New("removed, but failed to add activity entry")
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -21,10 +22,12 @@ type WatchedSeason struct {
|
||||
}
|
||||
|
||||
type WatchedSeasonAddRequest struct {
|
||||
WatchedID uint `json:"watchedId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
WatchedID uint `json:"watchedId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
addActivity ActivityType `json:"-"`
|
||||
addActivityDate time.Time `json:"-"`
|
||||
}
|
||||
|
||||
type WatchedSeasonAddResponse struct {
|
||||
@@ -33,7 +36,7 @@ type WatchedSeasonAddResponse struct {
|
||||
}
|
||||
|
||||
// Add/edit a watched season.
|
||||
func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest, at ActivityType) (WatchedSeasonAddResponse, error) {
|
||||
func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest) (WatchedSeasonAddResponse, error) {
|
||||
slog.Debug("Adding watched season item", "userId", userId, "watchedID", ar.WatchedID, "season", ar.SeasonNumber)
|
||||
// 1. Make sure watched item exists and it is the correct type (TV)
|
||||
var w Watched
|
||||
@@ -48,16 +51,19 @@ func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest, at A
|
||||
if w.Content.Type != SHOW {
|
||||
return WatchedSeasonAddResponse{}, errors.New("can't add watched season for non show content")
|
||||
}
|
||||
var found bool
|
||||
found := false
|
||||
updated := false
|
||||
for i, ws := range w.WatchedSeasons {
|
||||
if ws.SeasonNumber == ar.SeasonNumber {
|
||||
slog.Debug("Existing watched season item found, updating existing")
|
||||
found = true
|
||||
if ar.Status != "" {
|
||||
if ar.Status != "" && ar.Status != w.WatchedSeasons[i].Status {
|
||||
w.WatchedSeasons[i].Status = ar.Status
|
||||
updated = true
|
||||
}
|
||||
if ar.Rating != 0 {
|
||||
if ar.Rating != 0 && ar.Rating != w.WatchedSeasons[i].Rating {
|
||||
w.WatchedSeasons[i].Rating = ar.Rating
|
||||
updated = true
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -79,17 +85,28 @@ func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest, at A
|
||||
}
|
||||
// Add activity
|
||||
if found {
|
||||
if ar.Status != "" {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_STATUS_CHANGED, Data: string(json)})
|
||||
}
|
||||
if ar.Rating != 0 {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "rating": ar.Rating})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_RATING_CHANGED, Data: string(json)})
|
||||
// Only add change activity if we actually updated a value
|
||||
// (changing value to same value doesn't count).
|
||||
if updated {
|
||||
if ar.Status != "" {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_STATUS_CHANGED, Data: string(json)})
|
||||
}
|
||||
if ar.Rating != 0 {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "rating": ar.Rating})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_RATING_CHANGED, Data: string(json)})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status, "rating": ar.Rating})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_ADDED, Data: string(json)})
|
||||
act := ActivityAddRequest{WatchedID: w.ID, Type: SEASON_ADDED, Data: string(json)}
|
||||
if ar.addActivity != "" {
|
||||
act.Type = ar.addActivity
|
||||
}
|
||||
if !ar.addActivityDate.IsZero() {
|
||||
act.CustomDate = &ar.addActivityDate
|
||||
}
|
||||
addedActivity, _ = addActivity(db, userId, act)
|
||||
}
|
||||
return WatchedSeasonAddResponse{
|
||||
WatchedSeasons: w.WatchedSeasons,
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo-col.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>Watcharr</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { Activity } from "@/types";
|
||||
import { getOrdinalSuffix, months } from "./util/helpers";
|
||||
import { getOrdinalSuffix, months, seasonAndEpToReadable } from "./util/helpers";
|
||||
import ActivityEditor from "./ActivityEditor.svelte";
|
||||
|
||||
export let activity: Activity[] | undefined;
|
||||
export let wListId: number;
|
||||
|
||||
let clickedActivity: Activity;
|
||||
let groupedActivities: { [index: string]: any };
|
||||
let isActivityEditorVisible: boolean;
|
||||
|
||||
$: {
|
||||
groupedActivities = getGroupedActivity(activity);
|
||||
@@ -15,70 +19,143 @@
|
||||
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${data?.status ? ` as ${toFullTitleCase(data.status)}` : ""}${
|
||||
data?.rating ? ` with ${data.rating} Stars` : ""
|
||||
}`;
|
||||
}
|
||||
return "added to watched list";
|
||||
return "Added to Watched List";
|
||||
case "REMOVED_WATCHED":
|
||||
return "removed from watched list";
|
||||
return "Removed from Watched List";
|
||||
case "RATING_CHANGED":
|
||||
if (a.data) {
|
||||
return `rating changed to ${a.data}`;
|
||||
return `Rating Changed to ${a.data}`;
|
||||
}
|
||||
return "rating changed";
|
||||
return "Rating Changed";
|
||||
case "STATUS_CHANGED":
|
||||
if (a.data) {
|
||||
return `status changed to ${a.data?.toLowerCase()}`;
|
||||
return `Status Changed to ${toFullTitleCase(a.data)}`;
|
||||
}
|
||||
return "status changed";
|
||||
return "Status Changed";
|
||||
case "THOUGHTS_CHANGED":
|
||||
return "thoughts changed";
|
||||
return "Thoughts Changed";
|
||||
case "THOUGHTS_REMOVED":
|
||||
return "thoughts removed";
|
||||
return "Thoughts Removed";
|
||||
case "IMPORTED_WATCHED":
|
||||
return "imported";
|
||||
return "Imported";
|
||||
case "IMPORTED_WATCHED_JF":
|
||||
case "IMPORTED_WATCHED_PLEX":
|
||||
return "Synced";
|
||||
case "IMPORTED_RATING":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
if (data.rating) {
|
||||
return `Rating Changed to ${data.rating}`;
|
||||
} else {
|
||||
return "Added to Watchlist with No Rating";
|
||||
}
|
||||
}
|
||||
return "Imported Rating";
|
||||
case "IMPORTED_ADDED_WATCHED":
|
||||
case "IMPORTED_ADDED_WATCHED_JF":
|
||||
case "IMPORTED_ADDED_WATCHED_PLEX":
|
||||
return "Imported Watch Date";
|
||||
case "SEASON_ADDED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `season ${data.season} added as ${data.status?.toLowerCase()}`;
|
||||
return `Season ${data.season} Added as ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "season added";
|
||||
return "Season Added";
|
||||
case "SEASON_ADDED_JF":
|
||||
case "SEASON_ADDED_PLEX":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Season ${data.season} Synced as ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Season Synced";
|
||||
case "SEASON_RATING_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `changed season ${data.season} rating to ${data.rating}`;
|
||||
return `Changed Season ${data.season} Rating to ${data.rating}`;
|
||||
}
|
||||
return "season rating changed";
|
||||
return "Season Rating Changed";
|
||||
case "SEASON_STATUS_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `changed season ${data.season} status to ${data.status?.toLowerCase()}`;
|
||||
return `Changed Season ${data.season} Status to ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "season status changed";
|
||||
return "Season Status Changed";
|
||||
case "SEASON_REMOVED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `season ${data.season} status removed`;
|
||||
return `Season ${data.season} Status Removed`;
|
||||
}
|
||||
return "season removed";
|
||||
return "Season Removed";
|
||||
case "EPISODE_ADDED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Added ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
|
||||
}
|
||||
return "Episode Added";
|
||||
case "EPISODE_ADDED_JF":
|
||||
case "EPISODE_ADDED_PLEX":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Synced ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
|
||||
}
|
||||
return "Episode Synced";
|
||||
case "EPISODE_RATING_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Rating Changed to ${data.rating}`;
|
||||
}
|
||||
return "Episode Rating Changed";
|
||||
case "EPISODE_STATUS_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Status Changed to ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Episode Status Changed";
|
||||
case "EPISODE_REMOVED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Removed`;
|
||||
}
|
||||
return "Episode Removed";
|
||||
default:
|
||||
return a.type;
|
||||
}
|
||||
}
|
||||
|
||||
function toFullTitleCase(text: string | undefined) {
|
||||
if (text) {
|
||||
return text
|
||||
.split(" ")
|
||||
.map((l) => l[0].toUpperCase() + l.substring(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
function toDayTime(d: Date) {
|
||||
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} at ${d.toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get what will be the visible date the activity was 'created'.
|
||||
* @returns customDate if defined or createdAt if not.
|
||||
*/
|
||||
function getCreatedAtVis(a: Activity) {
|
||||
return Date.parse(a.customDate ?? a.createdAt);
|
||||
}
|
||||
|
||||
function getGroupedActivity(activities?: Activity[]) {
|
||||
activities = activities?.filter((a) => a.type);
|
||||
const a = activities?.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
||||
const a = activities?.sort((a, b) => getCreatedAtVis(b) - getCreatedAtVis(a));
|
||||
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 date = new Date(getCreatedAtVis(activity));
|
||||
const key = `${months[date.getMonth()]} ${date.getFullYear()}`;
|
||||
if (grouped[key]) {
|
||||
grouped[key].push(activity);
|
||||
@@ -89,8 +166,23 @@
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function openEditor(a: Activity) {
|
||||
clickedActivity = a;
|
||||
isActivityEditorVisible = true;
|
||||
return;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isActivityEditorVisible}
|
||||
<ActivityEditor
|
||||
watchedId={wListId}
|
||||
activity={clickedActivity}
|
||||
activityMessage={getMsg(clickedActivity)}
|
||||
onClose={() => (isActivityEditorVisible = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="activity">
|
||||
<h2>Activity</h2>
|
||||
{#if groupedActivities && Object.keys(groupedActivities).length > 0}
|
||||
@@ -99,11 +191,12 @@
|
||||
<h3>{k}</h3>
|
||||
|
||||
{#each groupedActivities[k] as a}
|
||||
{@const d = new Date(getCreatedAtVis(a))}
|
||||
<li>
|
||||
<span title={new Date(a.createdAt).toDateString()}>
|
||||
{toDayTime(new Date(a.createdAt))}
|
||||
</span>
|
||||
<span>{getMsg(a)}</span>
|
||||
<button class="unset" on:click={() => openEditor(a)}>
|
||||
<span title={d.toDateString()}>{toDayTime(d)}</span>
|
||||
<span>{getMsg(a)}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{/each}
|
||||
@@ -142,12 +235,16 @@
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
button {
|
||||
all: unset;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
span {
|
||||
width: max-content;
|
||||
@@ -159,7 +256,6 @@
|
||||
|
||||
&:last-child {
|
||||
background-color: $accent-color;
|
||||
text-transform: capitalize;
|
||||
color: $text-color;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
import { updateActivity, removeActivity } from "@/lib/util/api";
|
||||
import Modal from "./Modal.svelte";
|
||||
import type { Activity } from "@/types";
|
||||
import { notify } from "./util/notify";
|
||||
|
||||
export let watchedId: number;
|
||||
export let activity: Activity;
|
||||
export let activityMessage: string;
|
||||
export let onClose: () => void;
|
||||
|
||||
let isDateTimeChanged: boolean;
|
||||
let isDateTimeValid = true;
|
||||
let currentDateObject = new Date(Date.parse(activity.customDate ?? activity.createdAt));
|
||||
let currentDateString = dateToInputDateString(currentDateObject);
|
||||
let currentTimeString = dateToInputTimeString(currentDateObject);
|
||||
let selectedDateString = currentDateString;
|
||||
let selectedTimeString = currentTimeString;
|
||||
|
||||
$: {
|
||||
isDateTimeChanged =
|
||||
currentDateString != selectedDateString || currentTimeString != selectedTimeString;
|
||||
}
|
||||
|
||||
function dateToInputDateString(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = date.getDate().toString().padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function dateToInputTimeString(date: Date) {
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function validateNewDate() {
|
||||
try {
|
||||
const epochMillis = Date.parse(`${selectedDateString} ${selectedTimeString}`);
|
||||
const dateObj = new Date(epochMillis);
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
isDateTimeValid = false;
|
||||
return;
|
||||
}
|
||||
isDateTimeValid = true;
|
||||
return dateObj;
|
||||
} catch (err) {
|
||||
console.error("ActivityEditor: validateNewDate failed!", err);
|
||||
isDateTimeValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
function update() {
|
||||
const dateObj = validateNewDate();
|
||||
if (dateObj && isDateTimeValid && isDateTimeChanged) {
|
||||
updateActivity(watchedId, activity.id, dateObj);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
notify({ text: "Unable to try updating!", type: "error" });
|
||||
console.error(
|
||||
"ActivityEditor: Can't try updating, data missing/invalid:",
|
||||
dateObj,
|
||||
isDateTimeValid,
|
||||
isDateTimeChanged
|
||||
);
|
||||
}
|
||||
|
||||
function remove() {
|
||||
removeActivity(watchedId, activity.id);
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal title="Edit Activity" desc={activityMessage} maxWidth="400px" {onClose}>
|
||||
<div class="centered">
|
||||
<h3>Date</h3>
|
||||
<input
|
||||
id="activity-date"
|
||||
type="date"
|
||||
bind:value={selectedDateString}
|
||||
on:change={validateNewDate}
|
||||
class:invalid={!isDateTimeValid}
|
||||
/>
|
||||
<h3>Time</h3>
|
||||
<input
|
||||
id="activity-time"
|
||||
type="time"
|
||||
bind:value={selectedTimeString}
|
||||
on:change={validateNewDate}
|
||||
/>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="danger" on:click={remove}>Delete</button>
|
||||
<div>
|
||||
<button on:click={update} disabled={!(isDateTimeChanged && isDateTimeValid)}>Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
.centered {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
|
||||
button {
|
||||
margin-top: auto;
|
||||
width: max-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { Icon as IconT } from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
export let text: string;
|
||||
export let icon: IconT = "document";
|
||||
export let filesSelected: (f?: FileList | null) => void;
|
||||
export let allowSelectMultipleFiles = false;
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
let dragEnterTarget: EventTarget | null;
|
||||
let isDragOver = false;
|
||||
|
||||
function importFile() {
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener("change", () => {
|
||||
filesSelected(fileInput.files);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="drop-file-btn">
|
||||
<button
|
||||
on:click={importFile}
|
||||
on:dragover={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}}
|
||||
on:dragenter={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
dragEnterTarget = ev.target;
|
||||
console.log("enter");
|
||||
isDragOver = true;
|
||||
}}
|
||||
on:dragleave={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (dragEnterTarget === ev.target) {
|
||||
console.log("leave");
|
||||
isDragOver = false;
|
||||
}
|
||||
}}
|
||||
on:drop={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
filesSelected(ev.dataTransfer?.files);
|
||||
}}
|
||||
class={isDragOver ? "dragging-over" : ""}
|
||||
>
|
||||
<Icon i={isDragOver ? "add" : icon} wh="100%" />
|
||||
<div>
|
||||
<h4 class="norm">
|
||||
{#if isDragOver}
|
||||
Import {text}
|
||||
{:else}
|
||||
Browse For {text}
|
||||
{/if}
|
||||
</h4>
|
||||
</div>
|
||||
</button>
|
||||
<input type="file" multiple={allowSelectMultipleFiles} bind:this={fileInput} />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.drop-file-btn {
|
||||
button {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 180px;
|
||||
padding: 20px;
|
||||
background-color: $accent-color;
|
||||
border: unset;
|
||||
border-radius: 10px;
|
||||
user-select: none;
|
||||
transition: 180ms ease-in-out;
|
||||
|
||||
:global {
|
||||
#reel path {
|
||||
transition: 180ms ease-in-out;
|
||||
|
||||
&:first-of-type {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
fill: $text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.dragging-over {
|
||||
color: $bg-color;
|
||||
background-color: $accent-color-hover;
|
||||
|
||||
:global(#reel path:last-of-type) {
|
||||
fill: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
width: 0px;
|
||||
overflow: hidden;
|
||||
border: unset;
|
||||
background-color: transparent;
|
||||
position: absolute;
|
||||
top: -500px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -58,6 +58,10 @@
|
||||
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 === "plex"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width={wh} height={wh}>
|
||||
<path d="M256 70H148l108 186-108 186h108l108-186z" fill="#e5a00d" />
|
||||
</svg>
|
||||
{:else if i === "trash"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512">
|
||||
<path
|
||||
@@ -120,16 +124,10 @@
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "compass"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<circle cx="256" cy="256" r="24" />
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="32"
|
||||
d="M448 256a192 192 0 1 0-384 0 192 192 0 0 0 384 0z"
|
||||
/>
|
||||
<path
|
||||
d="m351 151-118 47a64 64 0 0 0-35 35l-47 118a8 8 0 0 0 10 10l118-47a64 64 0 0 0 35-35l47-118a8 8 0 0 0-10-10zm-95 129a24 24 0 1 1 24-24 24 24 0 0 1-24 24z"
|
||||
d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm105.07 113.33l-46.88 117.2a64 64 0 01-35.66 35.66l-117.2 46.88a8 8 0 01-10.4-10.4l46.88-117.2a64 64 0 0135.66-35.66l117.2-46.88a8 8 0 0110.4 10.4z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "document"}
|
||||
@@ -216,6 +214,82 @@
|
||||
d="M491 273.36a32.2 32.2 0 00-.1-34.76c-26.46-40.92-60.79-75.68-99.27-100.53C349 110.55 302 96 255.68 96a226.54 226.54 0 00-71.82 11.79 4 4 0 00-1.56 6.63l47.24 47.24a4 4 0 003.82 1.05 96 96 0 01116 116 4 4 0 001.05 3.81l67.95 68a4 4 0 005.4.24 343.81 343.81 0 0067.24-77.4zM256 352a96 96 0 01-93.3-118.63 4 4 0 00-1.05-3.81l-66.84-66.87a4 4 0 00-5.41-.23c-24.39 20.81-47 46.13-67.67 75.72a31.92 31.92 0 00-.64 35.54c26.41 41.33 60.39 76.14 98.28 100.65C162.06 402 207.92 416 255.68 416a238.22 238.22 0 0072.64-11.55 4 4 0 001.61-6.64l-47.47-47.46a4 4 0 00-3.81-1.05A96 96 0 01256 352z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "people"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M258.9 48C141.92 46.42 46.42 141.92 48 258.9c1.56 112.19 92.91 203.54 205.1 205.1 117 1.6 212.48-93.9 210.88-210.88C462.44 140.91 371.09 49.56 258.9 48zm-3.68 152.11c.21-1.2.44-2.4.71-3.59a66.46 66.46 0 0116.29-31.21c12.89-13.73 31.16-21.31 51.45-21.31a74.05 74.05 0 0125.06 4.26 66.69 66.69 0 0126.27 17.2 68.15 68.15 0 0118 42.14 78.46 78.46 0 010 11.4 86.19 86.19 0 01-8.2 31q-.76 1.59-1.59 3.15c-1.11 2.07-2.3 4.1-3.58 6.06a79.47 79.47 0 01-8.63 11c-13.12 14-29.92 21.73-47.31 21.73a59.61 59.61 0 01-19.17-3.18 63.47 63.47 0 01-6.1-2.43 70.76 70.76 0 01-22.07-16.12 83.76 83.76 0 01-22-51.32q-.27-3.88-.18-7.68a75.62 75.62 0 011.05-11.08zm-149.73 24.34a59.87 59.87 0 015.2-20.64 56.76 56.76 0 012.78-5.3 54.49 54.49 0 017.19-9.56 55.62 55.62 0 0114-10.82 56.84 56.84 0 018.11-3.64 63.85 63.85 0 0133.35-2.39 57 57 0 0130.78 17 57.86 57.86 0 0115.41 38.62c.05 2.11 0 4.23-.15 6.38a71.58 71.58 0 01-6 23.84 69.49 69.49 0 01-5.73 10.42 65.39 65.39 0 01-15.76 16.57c-1.5 1.07-3.06 2.07-4.67 3.07a54.21 54.21 0 01-10 4.65 49.31 49.31 0 01-16.2 2.76c-.93 0-1.86 0-2.78-.08a47.6 47.6 0 01-5.48-.62 51.19 51.19 0 01-5.35-1.23 53.54 53.54 0 01-7.72-2.89c-.84-.39-1.66-.8-2.48-1.23-18-9.49-31.57-29.16-34.23-52.12-.12-1.05-.22-2.1-.29-3.16a66.59 66.59 0 01.02-9.63zm53.92 178.6a177.27 177.27 0 01-61.94-70.65 4 4 0 011.62-5.26C117.67 316.69 141.4 311 163.82 311c17 0 30.7 2 42.69 5.88a8 8 0 012.59 13.77c-23.35 19-38.4 42.54-45.47 70.75a2.77 2.77 0 01-4.22 1.65zM256 432a175.12 175.12 0 01-65.7-12.72 4 4 0 01-2.4-4.46c.4-2.05.84-3.92 1.23-5.48 7.12-28.43 24.76-52 51-68.18 23.29-14.35 53-22.25 83.52-22.25 31.16 0 60 7.58 83.48 21.91a2.72 2.72 0 01.91 3.67A176.1 176.1 0 01256 432z"
|
||||
/>
|
||||
<path
|
||||
d="M161 295.28a47.6 47.6 0 01-5.48-.62 47.6 47.6 0 005.48.62zM134.64 178.13a55.62 55.62 0 00-14 10.82 54.49 54.49 0 00-7.19 9.56 54.49 54.49 0 017.19-9.56 55.62 55.62 0 0114-10.82zM216.17 257.89a71.58 71.58 0 006-23.84c.15-2.15.2-4.27.15-6.38q.08 3.15-.15 6.38a71.58 71.58 0 01-6 23.84zM134.64 178.13a56.84 56.84 0 018.11-3.64 56.84 56.84 0 00-8.11 3.64zM150.21 293.43a53.54 53.54 0 01-7.72-2.89 53.54 53.54 0 007.72 2.89zM105.78 237.19c2.66 23 16.26 42.63 34.23 52.12-18.01-9.49-31.57-29.16-34.23-52.12zM254.34 219a83.76 83.76 0 0022 51.32 70.76 70.76 0 0022.07 16.12 70.76 70.76 0 01-22.07-16.12 83.76 83.76 0 01-22-51.32q-.27-3.88-.18-7.68-.09 3.75.18 7.68zM304.5 288.82a63.47 63.47 0 01-6.1-2.43 63.47 63.47 0 006.1 2.43zM255.93 196.54a66.46 66.46 0 0116.29-31.21 66.46 66.46 0 00-16.29 31.21zM375 165.46a68.15 68.15 0 0118 42.14 68.15 68.15 0 00-18-42.14 66.69 66.69 0 00-26.27-17.2 66.69 66.69 0 0126.27 17.2zM393 219a86.19 86.19 0 01-8.2 31 86.19 86.19 0 008.2-31zM254.16 211.27a75.62 75.62 0 011.06-11.14 75.62 75.62 0 00-1.06 11.14zM383.19 253.16zM206.88 189.05a57.86 57.86 0 0115.41 38.62 57.86 57.86 0 00-15.41-38.62 57 57 0 00-30.78-17 57 57 0 0130.78 17zM190 288a54.21 54.21 0 01-10 4.65 54.21 54.21 0 0010-4.65zM105.49 224.45a59.87 59.87 0 015.2-20.64 59.87 59.87 0 00-5.2 20.64zM194.68 284.88C193.17 286 191.61 287 190 288c1.61-1 3.17-2 4.68-3.12zM216.17 257.89a69.49 69.49 0 01-5.73 10.42 69.49 69.49 0 005.73-10.42zM110.69 203.81a56.76 56.76 0 012.78-5.3 56.76 56.76 0 00-2.78 5.3zM194.68 284.88a65.39 65.39 0 0015.76-16.57 65.39 65.39 0 01-15.76 16.57z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "person"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M258.9 48C141.92 46.42 46.42 141.92 48 258.9c1.56 112.19 92.91 203.54 205.1 205.1 117 1.6 212.48-93.9 210.88-210.88C462.44 140.91 371.09 49.56 258.9 48zm126.42 327.25a4 4 0 01-6.14-.32 124.27 124.27 0 00-32.35-29.59C321.37 329 289.11 320 256 320s-65.37 9-90.83 25.34a124.24 124.24 0 00-32.35 29.58 4 4 0 01-6.14.32A175.32 175.32 0 0180 259c-1.63-97.31 78.22-178.76 175.57-179S432 158.81 432 256a175.32 175.32 0 01-46.68 119.25z"
|
||||
/>
|
||||
<path
|
||||
d="M256 144c-19.72 0-37.55 7.39-50.22 20.82s-19 32-17.57 51.93C191.11 256 221.52 288 256 288s64.83-32 67.79-71.24c1.48-19.74-4.8-38.14-17.68-51.82C293.39 151.44 275.59 144 256 144z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "person-add"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M288 256c52.79 0 99.43-49.71 104-110.82 2.27-30.7-7.36-59.33-27.12-80.6C345.33 43.57 318 32 288 32c-30.24 0-57.59 11.5-77 32.38-19.63 21.11-29.2 49.8-27 80.78C188.49 206.28 235.12 256 288 256zM495.38 439.76c-8.44-46.82-34.79-86.15-76.19-113.75C382.42 301.5 335.83 288 288 288s-94.42 13.5-131.19 38c-41.4 27.6-67.75 66.93-76.19 113.75-1.93 10.73.69 21.34 7.19 29.11A30.94 30.94 0 00112 480h352a30.94 30.94 0 0024.21-11.13c6.48-7.77 9.1-18.38 7.17-29.11zM104 288v-40h40a16 16 0 000-32h-40v-40a16 16 0 00-32 0v40H32a16 16 0 000 32h40v40a16 16 0 0032 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "person-minus"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M288 256c52.79 0 99.43-49.71 104-110.82 2.27-30.7-7.36-59.33-27.12-80.6C345.33 43.57 318 32 288 32c-30.24 0-57.59 11.5-77 32.38-19.63 21.11-29.2 49.8-27 80.78C188.49 206.28 235.12 256 288 256zM495.38 439.76c-8.44-46.82-34.79-86.15-76.19-113.75C382.42 301.5 335.83 288 288 288s-94.42 13.5-131.19 38c-41.4 27.6-67.75 66.93-76.19 113.75-1.93 10.73.69 21.34 7.19 29.11A30.94 30.94 0 00112 480h352a30.94 30.94 0 0024.21-11.13c6.48-7.77 9.1-18.38 7.17-29.11zM144 216H32a16 16 0 000 32h112a16 16 0 000-32z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "pencil"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="44"
|
||||
d="M358.62 129.28L86.49 402.08 70 442l39.92-16.49 272.8-272.13-24.1-24.1zM413.07 74.84l-11.79 11.78 24.1 24.1 11.79-11.79a16.51 16.51 0 000-23.34l-.75-.75a16.51 16.51 0 00-23.35 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "eye"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<circle cx="256" cy="256" r="64" />
|
||||
<path
|
||||
d="M490.84 238.6c-26.46-40.92-60.79-75.68-99.27-100.53C349 110.55 302 96 255.66 96c-42.52 0-84.33 12.15-124.27 36.11-40.73 24.43-77.63 60.12-109.68 106.07a31.92 31.92 0 00-.64 35.54c26.41 41.33 60.4 76.14 98.28 100.65C162 402 207.9 416 255.66 416c46.71 0 93.81-14.43 136.2-41.72 38.46-24.77 72.72-59.66 99.08-100.92a32.2 32.2 0 00-.1-34.76zM256 352a96 96 0 1196-96 96.11 96.11 0 01-96 96z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "star"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M394 480a16 16 0 01-9.39-3L256 383.76 127.39 477a16 16 0 01-24.55-18.08L153 310.35 23 221.2a16 16 0 019-29.2h160.38l48.4-148.95a16 16 0 0130.44 0l48.4 149H480a16 16 0 019.05 29.2L359 310.35l50.13 148.53A16 16 0 01394 480z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "movary"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 120 120"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<g transform="translate(0.000000,120.000000) scale(0.100000,-0.100000)" fill="currentColor">
|
||||
<path
|
||||
d="M470 1184 c-221 -47 -408 -236 -455 -459 -43 -204 16 -398 164 -545
|
||||
87 -87 164 -131 277 -160 375 -96 742 191 744 581 0 83 -30 202 -72 282 -48
|
||||
92 -165 205 -258 251 -129 62 -264 80 -400 50z m-147 -216 c12 -6 75 -51 140
|
||||
-100 64 -49 123 -91 131 -94 7 -3 67 35 132 84 168 125 169 125 193 112 11 -6
|
||||
25 -19 31 -30 6 -11 10 -141 10 -342 0 -358 -2 -368 -60 -368 -56 0 -60 18
|
||||
-60 288 0 208 -2 243 -15 248 -9 4 -54 -11 -100 -31 l-85 -38 -2 -211 -3 -211
|
||||
-33 0 -34 0 2 201 c0 162 -2 205 -14 216 -22 23 -164 81 -181 75 -13 -6 -15
|
||||
-41 -15 -253 0 -216 -2 -250 -17 -266 -19 -21 -66 -24 -82 -4 -8 10 -12 113
|
||||
-12 357 0 340 1 344 22 362 25 20 24 20 52 5z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -2,18 +2,21 @@
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
export let title: string;
|
||||
export let desc: string;
|
||||
export let desc: string | undefined = undefined;
|
||||
export let onClose: (() => void) | undefined = undefined;
|
||||
export let maxWidth = "1000px";
|
||||
</script>
|
||||
|
||||
<div class="backdrop"></div>
|
||||
<div class="modal">
|
||||
<div>
|
||||
<div style="max-width:{maxWidth};">
|
||||
{#if typeof onClose !== "undefined"}
|
||||
<button class="close" on:click={onClose}><Icon i="close" wh="20" /></button>
|
||||
{/if}
|
||||
<h3 class="norm">{title}</h3>
|
||||
<h5 class="norm">{desc}</h5>
|
||||
{#if desc}
|
||||
<h5 class="norm">{desc}</h5>
|
||||
{/if}
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,7 +51,6 @@
|
||||
flex-flow: column;
|
||||
min-width: 300px;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
max-height: calc(100% - 20px);
|
||||
background-color: $bg-color;
|
||||
border-radius: 10px;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let rating: number | undefined;
|
||||
export let onChange: (newRating: number) => void;
|
||||
|
||||
let hoveredRating: number | undefined;
|
||||
let shownRating: number | undefined;
|
||||
let ratingContainer: HTMLDivElement;
|
||||
let ratingText: HTMLSpanElement;
|
||||
|
||||
const ratingDesc = [
|
||||
"Apalling",
|
||||
@@ -19,40 +23,67 @@
|
||||
];
|
||||
|
||||
function handleStarClick(r: number) {
|
||||
if (r === rating) return;
|
||||
onChange(r);
|
||||
}
|
||||
|
||||
$: {
|
||||
if (hoveredRating !== undefined) shownRating = hoveredRating;
|
||||
else if (rating !== undefined) shownRating = rating;
|
||||
else shownRating = undefined;
|
||||
else if (rating !== undefined) {
|
||||
shownRating = rating;
|
||||
resetRatingText();
|
||||
} else shownRating = undefined;
|
||||
}
|
||||
|
||||
function handleStarHover(r: number) {
|
||||
function resetRatingText() {
|
||||
if (!ratingText) {
|
||||
console.warn("resetRatingText: ratingText is not defined yet.");
|
||||
return;
|
||||
}
|
||||
if (typeof rating === "number" && rating > 0) {
|
||||
ratingText.innerText = ratingDesc[rating - 1];
|
||||
} else {
|
||||
ratingText.innerText = "Select Your Rating";
|
||||
}
|
||||
}
|
||||
|
||||
function handleStarHover(
|
||||
ev: MouseEvent & {
|
||||
currentTarget: EventTarget & HTMLButtonElement;
|
||||
},
|
||||
r: number
|
||||
) {
|
||||
hoveredRating = r;
|
||||
// We set innerText instead of letting svelte update dom for us
|
||||
// since we need the new width of span right now.
|
||||
ratingText.innerText = ratingDesc[r - 1];
|
||||
const start = ratingContainer?.getBoundingClientRect()?.x;
|
||||
const starl = ev?.currentTarget?.getBoundingClientRect()?.left;
|
||||
const rb = ratingText?.getBoundingClientRect();
|
||||
ratingText.style.left = `${starl - start - rb.width / 2 + 11.5}px`;
|
||||
ratingText.style.transform = "unset";
|
||||
}
|
||||
|
||||
function handleStarHoverEnd() {
|
||||
hoveredRating = undefined;
|
||||
ratingText.style.left = "50%";
|
||||
ratingText.style.transform = "translateX(-50%)";
|
||||
resetRatingText();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
resetRatingText();
|
||||
});
|
||||
</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-container" bind:this={ratingContainer}>
|
||||
<span bind:this={ratingText}></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:mouseenter={(ev) => handleStarHover(ev, v)}
|
||||
on:mouseleave={() => handleStarHoverEnd()}
|
||||
>
|
||||
*
|
||||
@@ -65,6 +96,17 @@
|
||||
.rating-container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
overflow: visible;
|
||||
|
||||
& > span {
|
||||
position: relative;
|
||||
transition:
|
||||
left 100ms ease-in,
|
||||
transform 100ms ease-in;
|
||||
max-width: max-content;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.rating {
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
{#if season?.episodes?.length > 0}
|
||||
<ul>
|
||||
{#each season.episodes as ep}
|
||||
<SeasonsListEpisode {ep} />
|
||||
<SeasonsListEpisode {ep} {watchedItem} />
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
|
||||
@@ -1,17 +1,119 @@
|
||||
<script lang="ts">
|
||||
import type { TMDBSeasonDetailsEpisode } from "@/types";
|
||||
import type {
|
||||
TMDBSeasonDetailsEpisode,
|
||||
WatchedStatus,
|
||||
WatchedEpisodeAddResponse,
|
||||
Watched
|
||||
} from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { userSettings } from "@/store";
|
||||
import PosterRating from "./poster/PosterRating.svelte";
|
||||
import PosterStatus from "./poster/PosterStatus.svelte";
|
||||
import axios from "axios";
|
||||
import { notify } from "./util/notify";
|
||||
import { get } from "svelte/store";
|
||||
import { watchedList } from "@/store";
|
||||
|
||||
$: settings = $userSettings;
|
||||
|
||||
export let ep: TMDBSeasonDetailsEpisode;
|
||||
export let watchedItem: Watched;
|
||||
|
||||
let isHidden = false;
|
||||
|
||||
$: {
|
||||
if (settings) isHidden = settings.hideSpoilers;
|
||||
}
|
||||
|
||||
function updateWatchedEpisode(status?: WatchedStatus, rating?: number) {
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
axios
|
||||
.post<WatchedEpisodeAddResponse>(`/watched/episode`, {
|
||||
watchedId: watchedItem.id,
|
||||
seasonNumber: ep.season_number,
|
||||
episodeNumber: ep.episode_number,
|
||||
status,
|
||||
rating
|
||||
})
|
||||
.then((r) => {
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find((w) => w.id === watchedItem.id);
|
||||
if (!wEntry) {
|
||||
notify({
|
||||
id: nid,
|
||||
text: `Request succeeded, but failed to find local data. Please refresh.`,
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (r.status === 200) {
|
||||
wEntry.watchedEpisodes = r.data.watchedEpisodes;
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(r.data.addedActivity);
|
||||
} else {
|
||||
wEntry.activity = [r.data.addedActivity];
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
notify({ id: nid, text: `Saved!`, type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Update!", type: "error" });
|
||||
});
|
||||
}
|
||||
|
||||
function handleStatusClick(type: WatchedStatus | "DELETE") {
|
||||
if (type === "DELETE") {
|
||||
const ws = watchedItem.watchedEpisodes?.find(
|
||||
(s) => s.seasonNumber === ep.season_number && s.episodeNumber === ep.episode_number
|
||||
);
|
||||
if (!ws) {
|
||||
notify({
|
||||
text: "Failed to find watched episode id. Please try refreshing.",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
axios
|
||||
.delete(`/watched/episode/${ws.id}`)
|
||||
.then((r) => {
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find((w) => w.id === watchedItem.id);
|
||||
if (!wEntry) {
|
||||
notify({
|
||||
id: nid,
|
||||
text: `Request succeeded, but failed to find local data. Please refresh.`,
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (r.status === 200) {
|
||||
wEntry.watchedEpisodes = wEntry.watchedEpisodes?.filter((s) => s.id !== ws.id);
|
||||
if (r.data) {
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(r.data);
|
||||
} else {
|
||||
wEntry.activity = [r.data];
|
||||
}
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
notify({ id: nid, text: `Removed!`, type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Remove!", type: "error" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateWatchedEpisode(type);
|
||||
}
|
||||
|
||||
function handleStarClick(rating: number) {
|
||||
updateWatchedEpisode(undefined, rating);
|
||||
}
|
||||
</script>
|
||||
|
||||
<li class={isHidden ? "dont-spoil" : ""}>
|
||||
@@ -20,17 +122,54 @@
|
||||
{:else}
|
||||
<div class="no-still" />
|
||||
{/if}
|
||||
<span>
|
||||
<b>{ep.episode_number}</b>
|
||||
<span class="episode-name">{ep.name}</span>
|
||||
</span>
|
||||
<span
|
||||
class="rating"
|
||||
title={`TMDB Rating: ${ep.vote_average} out of 10 (based on ${ep.vote_count} votes)`}
|
||||
>
|
||||
<span>*</span>
|
||||
{Math.round(ep.vote_average * 10) / 10}
|
||||
</span>
|
||||
<div class="info">
|
||||
<div>
|
||||
<span>
|
||||
<b>{ep.episode_number}</b>
|
||||
<span class="episode-name">{ep.name}</span>
|
||||
{#if ep.runtime}
|
||||
<span class="episode-runtime" title="This episode has a runtime of {ep.runtime} minutes."
|
||||
>{ep.runtime}m</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
class="rating"
|
||||
title={`TMDB Rating: ${ep.vote_average} out of 10 (based on ${ep.vote_count} votes)`}
|
||||
>
|
||||
<span>*</span>
|
||||
{Math.round(ep.vote_average * 10) / 10}
|
||||
</span>
|
||||
</div>
|
||||
<span class="overview">{ep.overview}</span>
|
||||
</div>
|
||||
{#if watchedItem}
|
||||
{@const we = watchedItem.watchedEpisodes?.find(
|
||||
(s) => s.seasonNumber === ep.season_number && s.episodeNumber === ep.episode_number
|
||||
)}
|
||||
<div class="status-rating-ctr">
|
||||
<div class="rating" style={"width: 45px"}>
|
||||
<PosterRating
|
||||
rating={we?.rating}
|
||||
btnTooltip={`Episode ${ep.episode_number} Rating`}
|
||||
handleStarClick={(r) => handleStarClick(r)}
|
||||
minimal={true}
|
||||
direction="bot"
|
||||
hideStarWhenRated
|
||||
/>
|
||||
</div>
|
||||
<div class="status">
|
||||
<PosterStatus
|
||||
status={we?.status}
|
||||
btnTooltip={`Episode ${ep.episode_number} Status`}
|
||||
handleStatusClick={(t) => handleStatusClick(t)}
|
||||
direction="bot"
|
||||
width="100%"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isHidden}
|
||||
<button class="plain spoiler-text" on:click={() => (isHidden = false)}>
|
||||
<Icon i="eye-closed" wh={34} />
|
||||
@@ -66,6 +205,78 @@
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
|
||||
.episode-name,
|
||||
.episode-runtime {
|
||||
text-transform: lowercase;
|
||||
font-variant: small-caps;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.episode-runtime {
|
||||
font-size: 14px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
color: $rating-color;
|
||||
font-weight: bolder;
|
||||
overflow: hidden;
|
||||
|
||||
span {
|
||||
margin-top: 2px;
|
||||
font-family: "Rampart One";
|
||||
-webkit-text-stroke: 1px $rating-color;
|
||||
font-size: 25px;
|
||||
line-height: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-rating-ctr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-flow: column-reverse;
|
||||
gap: 10px;
|
||||
margin-bottom: auto;
|
||||
min-height: 40px;
|
||||
margin-left: auto;
|
||||
|
||||
div {
|
||||
transition: width 100ms ease;
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&.rating {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
&.status {
|
||||
width: 45px;
|
||||
min-height: 40px;
|
||||
height: 40px;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
padding: 3px 5px;
|
||||
|
||||
@@ -74,24 +285,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
color: $rating-color;
|
||||
font-weight: bolder;
|
||||
overflow: hidden;
|
||||
|
||||
span {
|
||||
font-family: "Rampart One";
|
||||
-webkit-text-stroke: 1px $rating-color;
|
||||
font-size: 25px;
|
||||
line-height: 0.7;
|
||||
margin-top: 1.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.spoiler-text {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
@@ -125,13 +318,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
img,
|
||||
.episode-name,
|
||||
img {
|
||||
.rating,
|
||||
.overview {
|
||||
transition: filter 150ms ease-out;
|
||||
}
|
||||
|
||||
&.dont-spoil {
|
||||
.episode-name {
|
||||
.episode-name,
|
||||
.rating,
|
||||
.overview {
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
@@ -147,6 +344,16 @@
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.status-rating-ctr {
|
||||
flex-flow: row;
|
||||
justify-content: center;
|
||||
margin-left: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.rating {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
width: 80px;
|
||||
aspect-ratio: 1;
|
||||
border: 10px solid $bg-color;
|
||||
background: radial-gradient(farthest-side, $text-color 98%, $bg-color) 0 0/20px 20px,
|
||||
background:
|
||||
radial-gradient(farthest-side, $text-color 98%, $bg-color) 0 0/20px 20px,
|
||||
radial-gradient(farthest-side, $text-color 98%, $bg-color) 100% 0/20px 20px,
|
||||
radial-gradient(farthest-side, $text-color 98%, $bg-color) 100% 100%/20px 20px,
|
||||
radial-gradient(farthest-side, $text-color 98%, $bg-color) 0 100%/20px 20px,
|
||||
linear-gradient($text-color 0 0) center/40px 40px, $bg-color;
|
||||
linear-gradient($text-color 0 0) center/40px 40px,
|
||||
$bg-color;
|
||||
background-repeat: no-repeat;
|
||||
filter: blur(4px) contrast(10);
|
||||
animation: spin 800ms infinite;
|
||||
@@ -25,7 +27,12 @@
|
||||
|
||||
@keyframes spin {
|
||||
100% {
|
||||
background-position: 100% 0, 100% 100%, 0 100%, 0 0, center;
|
||||
background-position:
|
||||
100% 0,
|
||||
100% 100%,
|
||||
0 100%,
|
||||
0 0,
|
||||
center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,44 +2,51 @@
|
||||
import Icon from "./Icon.svelte";
|
||||
import type { WatchedStatus } from "../types";
|
||||
import tooltip from "./actions/tooltip";
|
||||
import { toUnderstandableStatus } from "./util/helpers";
|
||||
|
||||
export let status: WatchedStatus | undefined;
|
||||
export let isForGame: boolean = false;
|
||||
export let onChange: (newStatus: WatchedStatus) => void;
|
||||
|
||||
function handleStatusClick(s: WatchedStatus) {
|
||||
if (s === status) return;
|
||||
onChange(s);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="status">
|
||||
<button
|
||||
class={status && status !== "PLANNED" ? "not-active" : ""}
|
||||
on:click={() => onChange("PLANNED")}
|
||||
use:tooltip={{ text: "Planned", pos: "top" }}
|
||||
on:click={() => handleStatusClick("PLANNED")}
|
||||
use:tooltip={{ text: toUnderstandableStatus("PLANNED", isForGame), pos: "top" }}
|
||||
>
|
||||
<Icon i="calendar" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "WATCHING" ? "not-active" : ""}
|
||||
on:click={() => onChange("WATCHING")}
|
||||
use:tooltip={{ text: "Watching", pos: "top" }}
|
||||
on:click={() => handleStatusClick("WATCHING")}
|
||||
use:tooltip={{ text: toUnderstandableStatus("WATCHING", isForGame), pos: "top" }}
|
||||
>
|
||||
<Icon i="clock" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "FINISHED" ? "not-active" : ""}
|
||||
on:click={() => onChange("FINISHED")}
|
||||
use:tooltip={{ text: "Finished", pos: "top" }}
|
||||
on:click={() => handleStatusClick("FINISHED")}
|
||||
use:tooltip={{ text: toUnderstandableStatus("FINISHED", isForGame), pos: "top" }}
|
||||
>
|
||||
<Icon i="check" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "HOLD" ? "not-active" : ""}
|
||||
on:click={() => onChange("HOLD")}
|
||||
use:tooltip={{ text: "On Hold", pos: "top" }}
|
||||
on:click={() => handleStatusClick("HOLD")}
|
||||
use:tooltip={{ text: toUnderstandableStatus("HOLD", isForGame), pos: "top" }}
|
||||
>
|
||||
<Icon i="pause" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "DROPPED" ? "not-active" : ""}
|
||||
on:click={() => onChange("DROPPED")}
|
||||
use:tooltip={{ text: "Dropped", pos: "top" }}
|
||||
on:click={() => handleStatusClick("DROPPED")}
|
||||
use:tooltip={{ text: toUnderstandableStatus("DROPPED", isForGame), pos: "top" }}
|
||||
>
|
||||
<Icon i="thumb-down" />
|
||||
</button>
|
||||
@@ -55,6 +62,7 @@
|
||||
|
||||
button {
|
||||
font-size: 10px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,41 +4,58 @@
|
||||
export let users: PublicUser[];
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
{#each users as user}
|
||||
<li title={user.username}><a href="/lists/{user.id}/{user.username}">{user.username}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div>
|
||||
<h2>Users</h2>
|
||||
<ul>
|
||||
{#each users as user}
|
||||
<li title={user.username}>
|
||||
<a href="/lists/{user.id}/{user.username}">
|
||||
<span>{user.username}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
margin: 5px 15px;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 5px 20px;
|
||||
list-style: none;
|
||||
justify-content: center;
|
||||
overflow: auto;
|
||||
padding: 15px;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
min-width: 50px;
|
||||
max-width: 200px;
|
||||
|
||||
a {
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
background-color: $accent-color;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $bg-color;
|
||||
background-color: $accent-color-hover;
|
||||
|
||||
@@ -3,21 +3,72 @@
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import Poster from "@/lib/poster/Poster.svelte";
|
||||
import PosterList from "@/lib/poster/PosterList.svelte";
|
||||
import { activeFilters, activeSort } from "@/store";
|
||||
import type { Watched } from "@/types";
|
||||
import { activeFilters, activeSort, serverFeatures, userSettings } from "@/store";
|
||||
import type { Watched, WatchedEpisode, WatchedSeason } from "@/types";
|
||||
import GamePoster from "./poster/GamePoster.svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { seasonAndEpToReadable } from "./util/helpers";
|
||||
|
||||
export let list: Watched[];
|
||||
export let isPublicList: boolean = false;
|
||||
|
||||
$: sort = $activeSort;
|
||||
$: filters = $activeFilters;
|
||||
$: watched = list
|
||||
.sort((a, b) => {
|
||||
$: watched = list;
|
||||
$: settings = $userSettings;
|
||||
$: features = $serverFeatures;
|
||||
|
||||
/**
|
||||
* Checks if content has been watched previously
|
||||
* by analyzing the watched entrys activity (with
|
||||
* the latest AI improvements added in of course.)
|
||||
*/
|
||||
function contentWatchedPreviously(w: Watched) {
|
||||
let wp = false;
|
||||
const relatedActivity = w.activity.filter(
|
||||
(a) =>
|
||||
a.type === "ADDED_WATCHED" ||
|
||||
a.type === "IMPORTED_ADDED_WATCHED" ||
|
||||
a.type === "IMPORTED_WATCHED" ||
|
||||
a.type === "STATUS_CHANGED"
|
||||
);
|
||||
for (let i = 0; i < relatedActivity.length; i++) {
|
||||
const ra = relatedActivity[i];
|
||||
if (ra.type === "IMPORTED_ADDED_WATCHED") {
|
||||
wp = true;
|
||||
break;
|
||||
} else if (ra.type === "ADDED_WATCHED" || ra.type === "IMPORTED_WATCHED") {
|
||||
const data = JSON.parse(ra.data);
|
||||
if (data?.status == "FINISHED") {
|
||||
wp = true;
|
||||
break;
|
||||
}
|
||||
} else if (ra.type === "STATUS_CHANGED") {
|
||||
if (ra.data === "FINISHED") {
|
||||
wp = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return wp;
|
||||
}
|
||||
|
||||
// Monsterous code for filters. Soz.
|
||||
$: (watched, filters, sort), filt();
|
||||
|
||||
function filt() {
|
||||
// Set watched to list and sort it.
|
||||
watched = list.sort((a, b) => {
|
||||
if (sort[0] === "DATEADDED" && sort[1] === "UP") {
|
||||
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
||||
} else if (sort[0] === "ALPHA") {
|
||||
if (sort[1] === "UP") return a.content.title.localeCompare(b.content.title);
|
||||
else if (sort[1] === "DOWN") return b.content.title.localeCompare(a.content.title);
|
||||
const atitle = a.content ? a.content.title : a.game ? a.game.name : "";
|
||||
const btitle = b.content ? b.content.title : b.game ? b.game.name : "";
|
||||
if (sort[1] === "UP") {
|
||||
return atitle.localeCompare(btitle);
|
||||
} else if (sort[1] === "DOWN") {
|
||||
return btitle.localeCompare(atitle);
|
||||
}
|
||||
} else if (sort[0] === "LASTCHANGED") {
|
||||
if (sort[1] === "UP") return Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
|
||||
else if (sort[1] === "DOWN") return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
|
||||
@@ -27,41 +78,167 @@
|
||||
}
|
||||
// default DATEADDED DOWN
|
||||
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
|
||||
})
|
||||
.filter((w) => {
|
||||
if (filters.status.length <= 0 && filters.type.length <= 0) return w;
|
||||
if (filters.status.length > 0 && filters.type.length > 0) {
|
||||
return (
|
||||
filters.status.includes(w.status?.toLowerCase()) && filters.type.includes(w.content.type)
|
||||
});
|
||||
// If games type filter enabled, but games disabled on server, make sure we remove it from active filters.
|
||||
if (!features.games) {
|
||||
const af = get(activeFilters);
|
||||
af.type = af.type?.filter((a) => a !== "game");
|
||||
filters.type = filters.type.filter((f) => f !== "game");
|
||||
}
|
||||
// Now apply filters to watch list.
|
||||
if (filters.status.length > 0 && filters.type.length > 0) {
|
||||
// If status and type filters applied, combine both.
|
||||
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
|
||||
watched = watched.filter(
|
||||
(w) =>
|
||||
(filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)) &&
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
} else {
|
||||
watched = watched.filter(
|
||||
(w) =>
|
||||
filters.status.includes(w.status?.toLowerCase()) &&
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
}
|
||||
if (filters.type.length > 0) {
|
||||
return filters.type.includes(w.content.type);
|
||||
} else if (filters.type.length > 0) {
|
||||
// Only filter type
|
||||
watched = watched.filter((w) =>
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
} else if (filters.status.length > 0) {
|
||||
// Only filter status
|
||||
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
|
||||
watched = watched.filter(
|
||||
(w) => filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)
|
||||
);
|
||||
} else {
|
||||
watched = watched.filter((w) => filters.status.includes(w.status?.toLowerCase()));
|
||||
}
|
||||
if (filters.status.length > 0) {
|
||||
return filters.status.includes(w.status?.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
// Get biggest season watching or biggest season watched.
|
||||
// This could probably be simpler but -_-
|
||||
function getLatestWatchedInTv(
|
||||
ws: WatchedSeason[] | undefined,
|
||||
we: WatchedEpisode[] | undefined
|
||||
): string {
|
||||
if ((!ws || ws.length <= 0) && (!we || we.length <= 0)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let biggestSeasonWatched = -1;
|
||||
let biggestSeasonWatching = -1;
|
||||
if (ws && ws.length > 0) {
|
||||
for (let i = 0; i < ws.length; i++) {
|
||||
const s = ws[i];
|
||||
if (s.status === "WATCHING") {
|
||||
if (s.seasonNumber > biggestSeasonWatching) {
|
||||
biggestSeasonWatching = s.seasonNumber;
|
||||
}
|
||||
} else if (s.status === "FINISHED") {
|
||||
if (s.seasonNumber > biggestSeasonWatched) {
|
||||
biggestSeasonWatched = s.seasonNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const season = biggestSeasonWatching >= 0 ? biggestSeasonWatching : biggestSeasonWatched;
|
||||
|
||||
// Look for biggest watched/watching episode in season if any.
|
||||
// Does same thing as above.
|
||||
let episode: WatchedEpisode | undefined;
|
||||
if (we && we.length > 0) {
|
||||
let biggestEpisodeWatched: WatchedEpisode | undefined;
|
||||
let biggestEpisodeWatching: WatchedEpisode | undefined;
|
||||
for (let i = 0; i < we.length; i++) {
|
||||
const s = we[i];
|
||||
if (season >= 0 && s.seasonNumber !== season) continue;
|
||||
if (s.status === "WATCHING") {
|
||||
if (!biggestEpisodeWatching) {
|
||||
biggestEpisodeWatching = s;
|
||||
}
|
||||
if (
|
||||
s.episodeNumber > biggestEpisodeWatching.episodeNumber ||
|
||||
s.seasonNumber > biggestEpisodeWatching.seasonNumber
|
||||
) {
|
||||
biggestEpisodeWatching = s;
|
||||
}
|
||||
} else if (s.status === "FINISHED") {
|
||||
if (!biggestEpisodeWatched) {
|
||||
biggestEpisodeWatched = s;
|
||||
}
|
||||
if (
|
||||
s.episodeNumber > biggestEpisodeWatched.episodeNumber ||
|
||||
s.seasonNumber > biggestEpisodeWatched.seasonNumber
|
||||
) {
|
||||
biggestEpisodeWatched = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (biggestEpisodeWatched || biggestEpisodeWatching) {
|
||||
episode =
|
||||
biggestEpisodeWatching !== undefined ? biggestEpisodeWatching : biggestEpisodeWatched;
|
||||
}
|
||||
}
|
||||
|
||||
if (season >= 0 && episode) {
|
||||
return seasonAndEpToReadable(season, episode.episodeNumber);
|
||||
} else if (season >= 0) {
|
||||
return `Season ${season}`;
|
||||
} else if (episode) {
|
||||
return seasonAndEpToReadable(episode.seasonNumber, episode.episodeNumber);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PosterList>
|
||||
{#if watched?.length > 0}
|
||||
{#each watched as w (w.id)}
|
||||
<Poster
|
||||
id={w.id}
|
||||
media={{
|
||||
id: w.content.tmdbId,
|
||||
poster_path: w.content.poster_path,
|
||||
title: w.content.title,
|
||||
overview: w.content.overview,
|
||||
media_type: w.content.type,
|
||||
release_date: w.content.release_date,
|
||||
first_air_date: w.content.first_air_date
|
||||
}}
|
||||
rating={w.rating}
|
||||
status={w.status}
|
||||
disableInteraction={isPublicList}
|
||||
/>
|
||||
{#if w.game}
|
||||
<GamePoster
|
||||
id={w.id}
|
||||
rating={w.rating}
|
||||
status={w.status}
|
||||
media={{
|
||||
id: w.game.igdbId,
|
||||
coverId: w.game.coverId,
|
||||
name: w.game.name,
|
||||
summary: w.game.summary,
|
||||
firstReleaseDate: w.game.releaseDate,
|
||||
poster: w.game.poster
|
||||
}}
|
||||
disableInteraction={isPublicList}
|
||||
extraDetails={{
|
||||
dateAdded: w.createdAt,
|
||||
dateModified: w.updatedAt
|
||||
}}
|
||||
/>
|
||||
{:else if w.content}
|
||||
<Poster
|
||||
id={w.id}
|
||||
media={{
|
||||
id: w.content.tmdbId,
|
||||
poster_path: w.content.poster_path,
|
||||
title: w.content.title,
|
||||
overview: w.content.overview,
|
||||
media_type: w.content.type,
|
||||
release_date: w.content.release_date,
|
||||
first_air_date: w.content.first_air_date
|
||||
}}
|
||||
rating={w.rating}
|
||||
status={w.status}
|
||||
disableInteraction={isPublicList}
|
||||
extraDetails={{
|
||||
dateAdded: w.createdAt,
|
||||
dateModified: w.updatedAt,
|
||||
lastWatched: getLatestWatchedInTv(w.watchedSeasons, w.watchedEpisodes)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="empty-list">
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function tooltip(node: HTMLElement, opts: ToolTipOptions) {
|
||||
|
||||
return {
|
||||
update(opts: ToolTipOptions) {
|
||||
text = opts.text;
|
||||
condition = opts.condition ?? true;
|
||||
},
|
||||
destroy() {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import type { ContentType, PublicUser, WatchedStatus } from "@/types";
|
||||
import HorizontalList from "../HorizontalList.svelte";
|
||||
import Modal from "../Modal.svelte";
|
||||
import axios from "axios";
|
||||
import Spinner from "../Spinner.svelte";
|
||||
import Error from "../Error.svelte";
|
||||
import Icon from "../Icon.svelte";
|
||||
import { watchedStatuses } from "../util/helpers";
|
||||
|
||||
interface FollowThoughts {
|
||||
followedUser: PublicUser;
|
||||
thoughts: string;
|
||||
status: WatchedStatus;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export let mediaType: ContentType | "game";
|
||||
// The tmdbId for movie/tv, igdbId for games.
|
||||
export let mediaId: number;
|
||||
|
||||
let modalShownFor: FollowThoughts | undefined = undefined;
|
||||
|
||||
async function getFollowsThoughts() {
|
||||
return (await axios.get<FollowThoughts[]>(`/follow/thoughts/${mediaType}/${mediaId}`)).data;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await getFollowsThoughts()}
|
||||
<Spinner />
|
||||
{:then fts}
|
||||
{#if fts?.length > 0}
|
||||
<HorizontalList title="Followed Thoughts">
|
||||
{#each fts as ft}
|
||||
<button
|
||||
class={["thoughts-card plain", ft.thoughts ? "" : "no-thoughts"].join(" ")}
|
||||
on:click={() => (modalShownFor = ft)}
|
||||
>
|
||||
<div>
|
||||
<h4 title={ft.followedUser.username}>{ft.followedUser.username}</h4>
|
||||
{#if ft.status}
|
||||
<div class="status-icon">
|
||||
<Icon i={watchedStatuses[ft.status]} wh={22} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if ft.rating}
|
||||
<span class="rating">
|
||||
<span>*</span>
|
||||
{ft.rating}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="thought">
|
||||
{ft.thoughts || "No thoughts yet."}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</HorizontalList>
|
||||
{/if}
|
||||
{:catch err}
|
||||
<Error error={err} pretty="Failed to load followed thoughts!" />
|
||||
{/await}
|
||||
|
||||
{#if modalShownFor}
|
||||
<Modal
|
||||
title={`${modalShownFor.followedUser.username}'s Thoughts`}
|
||||
onClose={() => (modalShownFor = undefined)}
|
||||
>
|
||||
<span>{modalShownFor.thoughts}</span>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.thoughts-card {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
padding: 15px 20px;
|
||||
background-color: $accent-color;
|
||||
fill: $text-color;
|
||||
border-radius: 10px;
|
||||
min-width: 250px;
|
||||
max-width: 250px;
|
||||
font-size: 16px;
|
||||
transition: background-color 200ms ease;
|
||||
text-align: unset;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 3px;
|
||||
|
||||
h4 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
font-size: 18px;
|
||||
font-weight: bolder;
|
||||
|
||||
span {
|
||||
font-family: "Rampart One";
|
||||
-webkit-text-stroke: 1px $text-color;
|
||||
font-size: 32px;
|
||||
line-height: 0.55;
|
||||
margin-top: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
margin-left: auto;
|
||||
margin-right: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
min-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.thought {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 9;
|
||||
-webkit-box-orient: vertical;
|
||||
hyphens: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&.no-thoughts {
|
||||
pointer-events: none;
|
||||
|
||||
.thought {
|
||||
font-weight: lighter;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $bg-color;
|
||||
fill: $bg-color;
|
||||
background-color: $accent-color-hover;
|
||||
|
||||
.rating span {
|
||||
-webkit-text-stroke: 1px $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -21,9 +21,5 @@
|
||||
gap: 15px;
|
||||
margin-top: auto;
|
||||
padding-top: 10px;
|
||||
|
||||
& span {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
<script lang="ts">
|
||||
export let homepage: string;
|
||||
export let homepage: string | undefined;
|
||||
export let title: string;
|
||||
export let releaseDate: string;
|
||||
export let releaseYear: string | number;
|
||||
export let voteAverage: number;
|
||||
export let voteCount: number;
|
||||
|
||||
const vote = Math.round(voteAverage * 10) / 10;
|
||||
// if voteAvg bigger than 10, it is out of 100, so no need to * by 10
|
||||
const vote = Math.round(voteAverage > 10 ? voteAverage : voteAverage * 10) / 10;
|
||||
</script>
|
||||
|
||||
<span class="title-container">
|
||||
<span class="title">
|
||||
<a href={homepage} target="_blank">{title}</a>
|
||||
<span>{new Date(Date.parse(releaseDate)).getFullYear()}</span>
|
||||
{#if homepage}
|
||||
<a href={homepage} target="_blank">{title}</a>
|
||||
{:else}
|
||||
<span class="t">{title}</span>
|
||||
{/if}
|
||||
<span class="year">{releaseYear}</span>
|
||||
</span>
|
||||
<span class="rating" title={`TMDB Rating: ${vote} out of 10 (based on ${voteCount} votes)`}>
|
||||
<span>*</span>
|
||||
@@ -25,7 +30,8 @@
|
||||
gap: 10px;
|
||||
|
||||
.title {
|
||||
a {
|
||||
a,
|
||||
span.t {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 30px;
|
||||
@@ -33,7 +39,7 @@
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
span {
|
||||
span.year {
|
||||
font-size: 20px;
|
||||
color: rgba($color: #fff, $alpha: 0.7);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { decode } from "blurhash";
|
||||
import Icon from "../Icon.svelte";
|
||||
import { baseURL } from "../util/api";
|
||||
import { onMount } from "svelte";
|
||||
import type { Image } from "@/types";
|
||||
|
||||
/**
|
||||
* Users avatar.
|
||||
*/
|
||||
export let img: Image | undefined;
|
||||
export let avatarDropped: ((ev: Event) => void) | undefined = undefined;
|
||||
|
||||
let bhCanvas: HTMLCanvasElement;
|
||||
let avatarInput: HTMLInputElement;
|
||||
|
||||
function avatarLoaded() {
|
||||
console.log("avatar loaded.. removing canvas");
|
||||
bhCanvas?.remove();
|
||||
}
|
||||
|
||||
$: {
|
||||
if (img?.path && img?.blurHash && bhCanvas) {
|
||||
const pixels = decode(img.blurHash, 80, 80);
|
||||
const ctx = bhCanvas.getContext("2d");
|
||||
if (ctx) {
|
||||
const imageData = ctx.createImageData(80, 80);
|
||||
imageData.data.set(pixels);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Ignore rest if avatarDropped not defined
|
||||
if (typeof avatarDropped !== "function") return;
|
||||
|
||||
avatarInput?.addEventListener("input", avatarDropped);
|
||||
|
||||
return () => {
|
||||
avatarInput?.removeEventListener("input", avatarDropped!);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={["img-ctr", typeof avatarDropped === "function" ? "" : "no-click"].join(" ")}>
|
||||
{#if img?.path}
|
||||
<canvas bind:this={bhCanvas} />
|
||||
<img src={`${baseURL}/${img.path}`} alt="" on:load={avatarLoaded} />
|
||||
{:else}
|
||||
<Icon i="person" wh="100%" />
|
||||
{/if}
|
||||
{#if typeof avatarDropped === "function"}
|
||||
<input bind:this={avatarInput} type="file" title="" accept=".jpg,.png,.gif,.webp" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.img-ctr {
|
||||
width: 80px;
|
||||
min-width: 80px;
|
||||
height: 80px;
|
||||
min-height: 80px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
fill: $text-color;
|
||||
|
||||
img {
|
||||
width: 80px;
|
||||
min-width: 80px;
|
||||
height: 80px;
|
||||
min-height: 80px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&:not(.no-click):hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { wlDetailedView } from "@/store";
|
||||
import type { WLDetailedViewOption } from "@/types";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
function detailClicked(d: WLDetailedViewOption) {
|
||||
let dv = get(wlDetailedView);
|
||||
if (dv.includes(d)) {
|
||||
dv = dv.filter((a) => a !== d);
|
||||
} else {
|
||||
dv.push(d);
|
||||
}
|
||||
wlDetailedView.update((a) => (a = dv));
|
||||
}
|
||||
|
||||
$: dve = $wlDetailedView;
|
||||
</script>
|
||||
|
||||
<div class="menu">
|
||||
<div class="inner">
|
||||
<h4 class="norm sm-caps">Shown Details</h4>
|
||||
<button
|
||||
class={`plain ${dve?.includes("statusRating") ? "on" : ""}`}
|
||||
on:click={() => detailClicked("statusRating")}
|
||||
>
|
||||
Status & Rating
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${dve?.includes("lastWatched") ? "on" : ""}`}
|
||||
on:click={() => detailClicked("lastWatched")}
|
||||
>
|
||||
Watching Season
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${dve?.includes("dateAdded") ? "on" : ""}`}
|
||||
on:click={() => detailClicked("dateAdded")}
|
||||
>
|
||||
Date Added
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${dve?.includes("dateModified") ? "on" : ""}`}
|
||||
on:click={() => detailClicked("dateModified")}
|
||||
>
|
||||
Date Modified
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div.menu {
|
||||
width: 200px;
|
||||
right: 58px;
|
||||
|
||||
&:before {
|
||||
left: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
div.inner {
|
||||
h4 {
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
& > button {
|
||||
text-transform: capitalize;
|
||||
position: relative;
|
||||
|
||||
&.on::before {
|
||||
content: "\2713";
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 12px;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { activeFilters, serverFeatures } from "@/store";
|
||||
import type { Filters } from "@/types";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
function filterClicked(type: keyof Filters, f: string) {
|
||||
const af = get(activeFilters);
|
||||
if (af[type]?.includes(f)) {
|
||||
af[type] = af[type]?.filter((a) => a !== f);
|
||||
} else {
|
||||
af[type]?.push(f);
|
||||
}
|
||||
activeFilters.update((a) => (a = af));
|
||||
}
|
||||
|
||||
$: filter = $activeFilters;
|
||||
$: features = $serverFeatures;
|
||||
</script>
|
||||
|
||||
<div class="menu">
|
||||
<div class="inner">
|
||||
<h4 class="norm sm-caps">type</h4>
|
||||
<div class="type-filter">
|
||||
<button
|
||||
class={`${filter.type.includes("tv") ? "active" : ""}`}
|
||||
on:click={() => filterClicked("type", "tv")}
|
||||
>
|
||||
SHOW
|
||||
</button>
|
||||
<button
|
||||
class={`${filter.type.includes("movie") ? "active" : ""}`}
|
||||
on:click={() => filterClicked("type", "movie")}
|
||||
>
|
||||
MOVIE
|
||||
</button>
|
||||
{#if features.games}
|
||||
<button
|
||||
class={`${filter.type.includes("game") ? "active" : ""}`}
|
||||
on:click={() => filterClicked("type", "game")}
|
||||
>
|
||||
GAME
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<h4 class="norm sm-caps">status</h4>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("planned") ? "on" : ""}`}
|
||||
on:click={() => filterClicked("status", "planned")}
|
||||
>
|
||||
planned
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("watching") ? "on" : ""}`}
|
||||
on:click={() => filterClicked("status", "watching")}
|
||||
>
|
||||
watching
|
||||
{#if features.games}
|
||||
(playing)
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("finished") ? "on" : ""}`}
|
||||
on:click={() => filterClicked("status", "finished")}
|
||||
>
|
||||
finished
|
||||
{#if features.games}
|
||||
(played)
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("hold") ? "on" : ""}`}
|
||||
on:click={() => filterClicked("status", "hold")}
|
||||
>
|
||||
on hold
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("dropped") ? "on" : ""}`}
|
||||
on:click={() => filterClicked("status", "dropped")}
|
||||
>
|
||||
dropped
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div.menu {
|
||||
width: 200px;
|
||||
right: 35px;
|
||||
|
||||
&:before {
|
||||
left: 23px;
|
||||
}
|
||||
}
|
||||
|
||||
div.inner {
|
||||
h4 {
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
& > button {
|
||||
text-transform: capitalize;
|
||||
position: relative;
|
||||
|
||||
&.on::before {
|
||||
content: "\2713";
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 7.5px;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.type-filter {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
width: 100%;
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
padding: 8px 0;
|
||||
width: 100%;
|
||||
|
||||
&:first-of-type {
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
&:not(:first-of-type) {
|
||||
border-left: unset;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { follows } from "@/store";
|
||||
|
||||
export let close: () => {};
|
||||
|
||||
$: following = $follows;
|
||||
</script>
|
||||
|
||||
<div class="menu">
|
||||
<div>
|
||||
{#if following?.length > 0}
|
||||
<h4 class="norm sm-caps">following</h4>
|
||||
<div class="list">
|
||||
{#each following as f}
|
||||
<a href="/lists/{f.followedUser.id}/{f.followedUser.username}" on:click={() => close()}>
|
||||
{f.followedUser.username}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span style="margin-top: 0;">You are not following anyone.</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
width: 180px;
|
||||
|
||||
&:before {
|
||||
right: 53px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
position: sticky;
|
||||
top: -10px;
|
||||
background-color: $bg-color;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
a {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { activeSort } from "@/store";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
function sortClicked(type: string, modeType: string = "UPDOWN") {
|
||||
const af = get(activeSort);
|
||||
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;
|
||||
}
|
||||
activeSort.update((af) => (af = [type, mode]));
|
||||
}
|
||||
|
||||
$: sort = $activeSort;
|
||||
</script>
|
||||
|
||||
<div class="menu sort-menu">
|
||||
<div>
|
||||
<button
|
||||
class={`plain ${sort[0] == "DATEADDED" ? sort[1].toLowerCase() : ""}`}
|
||||
on:click={() => sortClicked("DATEADDED")}
|
||||
>
|
||||
Date Added
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${sort[0] == "LASTCHANGED" ? sort[1].toLowerCase() : ""}`}
|
||||
on:click={() => sortClicked("LASTCHANGED")}
|
||||
>
|
||||
Last Changed
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${sort[0] == "RATING" ? sort[1].toLowerCase() : ""}`}
|
||||
on:click={() => sortClicked("RATING")}
|
||||
>
|
||||
Rating
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${sort[0] == "ALPHA" ? sort[1].toLowerCase() : ""}`}
|
||||
on:click={() => sortClicked("ALPHA")}
|
||||
>
|
||||
Alphabetical
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
width: 180px;
|
||||
|
||||
&:before {
|
||||
left: 21px;
|
||||
}
|
||||
|
||||
& > div {
|
||||
& > 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,412 @@
|
||||
<script lang="ts">
|
||||
import type { ExtraDetails, ExtraDetailsGame, Image, WatchedStatus } from "@/types";
|
||||
import Icon from "../Icon.svelte";
|
||||
import {
|
||||
addClassToParent,
|
||||
calculateTransformOrigin,
|
||||
getOrdinalSuffix,
|
||||
isTouch,
|
||||
monthsShort,
|
||||
watchedStatuses
|
||||
} from "@/lib/util/helpers";
|
||||
import { goto } from "$app/navigation";
|
||||
import { baseURL, removeWatched, updatePlayed } from "../util/api";
|
||||
import { notify } from "../util/notify";
|
||||
import { onMount } from "svelte";
|
||||
import PosterStatus from "./PosterStatus.svelte";
|
||||
import PosterRating from "./PosterRating.svelte";
|
||||
import { wlDetailedView } from "@/store";
|
||||
import { page } from "$app/stores";
|
||||
import { decode } from "blurhash";
|
||||
|
||||
export let id: number | undefined = undefined; // Watched list id
|
||||
export let media: {
|
||||
id: number;
|
||||
coverId: string;
|
||||
firstReleaseDate?: string | number;
|
||||
name: string;
|
||||
summary?: string;
|
||||
poster?: Image;
|
||||
};
|
||||
export let rating: number | undefined = undefined;
|
||||
export let status: WatchedStatus | undefined = undefined;
|
||||
export let small = false;
|
||||
export let disableInteraction = false;
|
||||
export let hideButtons = false;
|
||||
export let extraDetails: ExtraDetailsGame | undefined = undefined;
|
||||
// When provided, default click handlers will instead run this callback.
|
||||
export let onClick: (() => void) | undefined = undefined;
|
||||
|
||||
$: dve = $wlDetailedView;
|
||||
|
||||
// If poster is active (scaled up)
|
||||
let posterActive = false;
|
||||
|
||||
let containerEl: HTMLDivElement;
|
||||
let bhCanvas: HTMLCanvasElement;
|
||||
|
||||
const title = `${media.name}`;
|
||||
const poster = media.poster?.path
|
||||
? `${baseURL}/${media.poster.path}`
|
||||
: `https://images.igdb.com/igdb/image/upload/t_cover_big/${media.coverId}.jpg`;
|
||||
const link = media.id ? `/game/${media.id}` : undefined;
|
||||
const dateStr = media.firstReleaseDate;
|
||||
const year = dateStr ? new Date(dateStr).getFullYear() : undefined;
|
||||
|
||||
function handleStarClick(r: number) {
|
||||
if (r == rating) return;
|
||||
updatePlayed(media.id, undefined, r);
|
||||
}
|
||||
|
||||
function handleStatusClick(type: WatchedStatus | "DELETE") {
|
||||
if (type === "DELETE") {
|
||||
if (!id) {
|
||||
notify({ text: "Content has no watched list id, can't delete.", type: "error" });
|
||||
return;
|
||||
}
|
||||
removeWatched(id);
|
||||
return;
|
||||
}
|
||||
if (type == status) return;
|
||||
updatePlayed(media.id, type);
|
||||
}
|
||||
|
||||
function handleInnerKeyUp(e: KeyboardEvent) {
|
||||
console.log(e.target);
|
||||
if (e.key === "Enter" && (e.target as HTMLElement)?.id === "ilikemoviessueme") {
|
||||
if (typeof onClick !== "undefined") {
|
||||
onClick();
|
||||
return;
|
||||
}
|
||||
if (link) {
|
||||
goto(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(e: number) {
|
||||
if (!e) {
|
||||
return "Unknown";
|
||||
}
|
||||
const d = new Date(e);
|
||||
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]} '${String(
|
||||
d.getFullYear()
|
||||
).substring(2, 4)}`;
|
||||
}
|
||||
|
||||
$: {
|
||||
if (media.poster?.path && media.poster?.blurHash && bhCanvas) {
|
||||
const pixels = decode(media.poster.blurHash, 170, 256);
|
||||
const ctx = bhCanvas.getContext("2d");
|
||||
if (ctx) {
|
||||
const imageData = ctx.createImageData(170, 256);
|
||||
imageData.data.set(pixels);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (small && containerEl) {
|
||||
containerEl.classList.add("small");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- HACK: disabled this issue for now, it should probably be fixed properly -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<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 || (!media.coverId && !media.poster?.path) ? " details-shown" : ""}`}
|
||||
bind:this={containerEl}
|
||||
>
|
||||
{#if poster && (media.coverId || media.poster?.path)}
|
||||
{#if media?.poster?.blurHash}
|
||||
<canvas width="170" height="256" bind:this={bhCanvas} class="img-loader" />
|
||||
{:else}
|
||||
<div class="img-loader"></div>
|
||||
{/if}
|
||||
<img
|
||||
loading="lazy"
|
||||
src={poster}
|
||||
alt=""
|
||||
on:load={(e) => {
|
||||
addClassToParent(e, "img-loaded");
|
||||
}}
|
||||
on:error={(e) => {
|
||||
addClassToParent(e, "details-shown");
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if $page.url?.pathname === "/" && extraDetails && dve && dve.length > 0}
|
||||
<div class="extra-details">
|
||||
<!--
|
||||
This is one line because svelte leaves whitespace,
|
||||
which causes the :empty css tag to not work.
|
||||
Can be reverted when its possible to trim whitespace in svelte
|
||||
OR when :empty tag is updated in browsers to new spec and counts whitespace as empty.
|
||||
So ye this is probably gonna be one line until the end of time.
|
||||
-->
|
||||
<!-- prettier-ignore -->
|
||||
<div>{#if dve.includes("dateAdded")}<span title="Date added to watch list"><i><Icon i="calendar" /></i><span>{formatDate(Date.parse(extraDetails.dateAdded))}</span></span>{/if}{#if dve.includes("dateModified")}<span title="Date last modified"><i><Icon i="pencil" wh={15} /></i><span>{formatDate(Date.parse(extraDetails.dateModified))}</span></span>{/if}{#if dve.includes("statusRating")}<span class="status-rating" title="Status and Rating"><i><Icon i="star" /></i><span>{rating}</span>{#if status}<i><Icon i={watchedStatuses[status]} wh={15} /></i>{/if}</span>{/if}</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
on:click={() => {
|
||||
if (typeof onClick !== "undefined") {
|
||||
onClick();
|
||||
return;
|
||||
}
|
||||
if (posterActive && link) goto(link);
|
||||
}}
|
||||
on:keyup={handleInnerKeyUp}
|
||||
id="ilikemoviessueme"
|
||||
class="inner"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<h2>
|
||||
{#if typeof onClick === "undefined" && link}
|
||||
<a data-sveltekit-preload-data="tap" href={link}>
|
||||
{title}
|
||||
</a>
|
||||
{:else}
|
||||
{title}
|
||||
{/if}
|
||||
{#if year}
|
||||
<time>{year}</time>
|
||||
{/if}
|
||||
</h2>
|
||||
<span>{media.summary}</span>
|
||||
|
||||
{#if !hideButtons}
|
||||
<div class="buttons">
|
||||
<PosterRating {rating} {handleStarClick} {disableInteraction} />
|
||||
<PosterStatus {status} {handleStatusClick} {disableInteraction} isForGame={true} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<style lang="scss">
|
||||
li.active {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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;
|
||||
// aspect-ratio: 2/3;
|
||||
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%;
|
||||
}
|
||||
|
||||
div.img-loader {
|
||||
background-color: gray;
|
||||
background: linear-gradient(359deg, #5c5c5c, #2c2929, #2c2424);
|
||||
background-size: 400% 400%;
|
||||
animation: imgloader 4s ease infinite;
|
||||
|
||||
@keyframes imgloader {
|
||||
0% {
|
||||
background-position: 50% 0%;
|
||||
}
|
||||
50% {
|
||||
background-position: 50% 100%;
|
||||
}
|
||||
100% {
|
||||
background-position: 50% 0%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
canvas.img-loader {
|
||||
animation: cimgloader 4s ease infinite;
|
||||
|
||||
@keyframes cimgloader {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.extra-details {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
width: 160px;
|
||||
color: white;
|
||||
background-color: $poster-extra-detail-bg-color;
|
||||
border-radius: 10px;
|
||||
transition: opacity 100ms ease-out;
|
||||
|
||||
& > div {
|
||||
padding: 8px 3px;
|
||||
|
||||
&:empty {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
& > span {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 15px;
|
||||
font-weight: bold;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
width: 15px;
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-rating i:last-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.inner {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: transparent;
|
||||
transition: opacity 150ms cubic-bezier(0.19, 1, 0.22, 1);
|
||||
|
||||
h2 {
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 18px;
|
||||
color: white;
|
||||
word-wrap: break-word;
|
||||
|
||||
a {
|
||||
color: white;
|
||||
}
|
||||
|
||||
time {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
color: white;
|
||||
margin: 5px 0 5px 0;
|
||||
font-size: 9px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 5;
|
||||
-webkit-box-orient: vertical;
|
||||
hyphens: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
margin-top: auto;
|
||||
gap: 10px;
|
||||
height: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
&.small .inner span {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
transform: scale(1.3);
|
||||
z-index: 99;
|
||||
|
||||
.extra-details {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.small:hover,
|
||||
&.small:focus-within {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within,
|
||||
&:global(.details-shown) {
|
||||
img {
|
||||
filter: blur(4px) grayscale(80%);
|
||||
// This makes the background very dark,
|
||||
// but atleast the text is visible.. may want to change later.
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.inner {
|
||||
color: white;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,8 @@
|
||||
const link = id ? `/person/${id}` : undefined;
|
||||
</script>
|
||||
|
||||
<!-- Quick fix to ignore error, should be fixed -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<li
|
||||
on:mouseenter={(e) => calculateTransformOrigin(e)}
|
||||
on:focusin={(e) => calculateTransformOrigin(e)}
|
||||
@@ -89,7 +91,7 @@
|
||||
background-size: 400% 400%;
|
||||
animation: imgloader 4s ease infinite;
|
||||
|
||||
@-webkit-keyframes imgloader {
|
||||
@keyframes imgloader {
|
||||
0% {
|
||||
background-position: 50% 0%;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { MediaType, WatchedStatus } from "@/types";
|
||||
import type { ExtraDetails, MediaType, WLDetailedViewOption, WatchedStatus } from "@/types";
|
||||
import Icon from "../Icon.svelte";
|
||||
import {
|
||||
addClassToParent,
|
||||
calculateTransformOrigin,
|
||||
getOrdinalSuffix,
|
||||
isTouch,
|
||||
monthsShort,
|
||||
watchedStatuses
|
||||
} from "@/lib/util/helpers";
|
||||
import { goto } from "$app/navigation";
|
||||
import tooltip from "../actions/tooltip";
|
||||
import { removeWatched, updateWatched } from "../util/api";
|
||||
import { baseURL, removeWatched, updateWatched } from "../util/api";
|
||||
import { notify } from "../util/notify";
|
||||
import { onMount } from "svelte";
|
||||
import PosterStatus from "./PosterStatus.svelte";
|
||||
import PosterRating from "./PosterRating.svelte";
|
||||
import { wlDetailedView } from "@/store";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
export let id: number | undefined = undefined; // Watched list id
|
||||
export let media: {
|
||||
@@ -31,16 +35,24 @@
|
||||
export let small = false;
|
||||
export let disableInteraction = false;
|
||||
export let hideButtons = false;
|
||||
export let extraDetails: ExtraDetails | undefined = undefined;
|
||||
// When provided, default click handlers will instead run this callback.
|
||||
export let onClick: (() => void) | undefined = undefined;
|
||||
|
||||
$: dve = $wlDetailedView;
|
||||
|
||||
// If poster is active (scaled up)
|
||||
let posterActive = false;
|
||||
|
||||
let containerEl: HTMLDivElement;
|
||||
|
||||
const title = media.title || media.name;
|
||||
const poster = `https://image.tmdb.org/t/p/w500${media.poster_path}`;
|
||||
// For now, if the content is on watched list, we can assume we have a local
|
||||
// cached image. Could be improved, since we could have a cached image for
|
||||
// show not on someone elses watched list.
|
||||
const poster = id
|
||||
? `${baseURL}/img${media.poster_path}`
|
||||
: `https://image.tmdb.org/t/p/w500${media.poster_path}`;
|
||||
const link = media.id ? `/${media.media_type}/${media.id}` : undefined;
|
||||
const dateStr = media.release_date || media.first_air_date;
|
||||
const year = dateStr ? new Date(dateStr).getFullYear() : undefined;
|
||||
@@ -59,6 +71,7 @@
|
||||
removeWatched(id);
|
||||
return;
|
||||
}
|
||||
if (type == status) return;
|
||||
updateWatched(media.id, media.media_type, type);
|
||||
}
|
||||
|
||||
@@ -75,6 +88,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(e: number) {
|
||||
if (!e) {
|
||||
return "Unknown";
|
||||
}
|
||||
const d = new Date(e);
|
||||
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]} '${String(
|
||||
d.getFullYear()
|
||||
).substring(2, 4)}`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (small && containerEl) {
|
||||
containerEl.classList.add("small");
|
||||
@@ -97,10 +120,13 @@
|
||||
on:mouseleave={() => (posterActive = false)}
|
||||
on:click={() => (posterActive = true)}
|
||||
on:keypress={() => console.log("on kpress")}
|
||||
class={`${posterActive ? "active " : ""}${disableInteraction ? "interaction-disabled" : ""}`}
|
||||
class={`${posterActive ? "active " : ""}`}
|
||||
>
|
||||
<div class={`container${!poster ? " details-shown" : ""}`} bind:this={containerEl}>
|
||||
{#if poster}
|
||||
<div
|
||||
class={`container${!poster || !media.poster_path ? " details-shown" : ""}`}
|
||||
bind:this={containerEl}
|
||||
>
|
||||
{#if poster && media.poster_path}
|
||||
<div class="img-loader" />
|
||||
<img
|
||||
loading="lazy"
|
||||
@@ -114,6 +140,19 @@
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if $page.url?.pathname === "/" && extraDetails && dve && dve.length > 0}
|
||||
<div class="extra-details">
|
||||
<!--
|
||||
This is one line because svelte leaves whitespace,
|
||||
which causes the :empty css tag to not work.
|
||||
Can be reverted when its possible to trim whitespace in svelte
|
||||
OR when :empty tag is updated in browsers to new spec and counts whitespace as empty.
|
||||
So ye this is probably gonna be one line until the end of time.
|
||||
-->
|
||||
<!-- prettier-ignore -->
|
||||
<div>{#if dve.includes("dateAdded")}<span title="Date added to watch list"><i><Icon i="calendar" /></i><span>{formatDate(Date.parse(extraDetails.dateAdded))}</span></span>{/if}{#if dve.includes("dateModified")}<span title="Date last modified"><i><Icon i="pencil" wh={15} /></i><span>{formatDate(Date.parse(extraDetails.dateModified))}</span></span>{/if}{#if extraDetails.lastWatched && dve.includes("lastWatched")}<span title="Latest season watched"><i><Icon i="play" wh={15} /></i><span>{extraDetails.lastWatched}</span></span>{/if}{#if dve.includes("statusRating")}<span class="status-rating" title="Status and Rating"><i><Icon i="star" /></i><span>{rating}</span>{#if status}<i><Icon i={watchedStatuses[status]} wh={15} /></i>{/if}</span>{/if}</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
on:click={() => {
|
||||
if (typeof onClick !== "undefined") {
|
||||
@@ -145,7 +184,7 @@
|
||||
{#if !hideButtons}
|
||||
<div class="buttons">
|
||||
<PosterRating {rating} {handleStarClick} {disableInteraction} />
|
||||
<PosterStatus {status} {handleStatusClick} />
|
||||
<PosterStatus {status} {handleStatusClick} {disableInteraction} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -157,27 +196,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
li.interaction-disabled {
|
||||
button {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
background-color: transparent;
|
||||
border: unset;
|
||||
fill: white;
|
||||
color: white;
|
||||
|
||||
span {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.unrated-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 15px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
@@ -224,6 +242,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
.extra-details {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
width: 160px;
|
||||
color: white;
|
||||
background-color: $poster-extra-detail-bg-color;
|
||||
border-radius: 10px;
|
||||
transition: opacity 100ms ease-out;
|
||||
|
||||
& > div {
|
||||
padding: 8px 3px;
|
||||
|
||||
&:empty {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
& > span {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 15px;
|
||||
font-weight: bold;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
width: 15px;
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-rating i:last-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.inner {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
@@ -285,6 +351,10 @@
|
||||
&:focus-within {
|
||||
transform: scale(1.3);
|
||||
z-index: 99;
|
||||
|
||||
.extra-details {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.small:hover,
|
||||
|
||||
@@ -7,12 +7,17 @@
|
||||
export let minimal = false;
|
||||
export let direction: "top" | "bot" = "top";
|
||||
export let btnTooltip: string = "";
|
||||
export let hideStarWhenRated = false;
|
||||
|
||||
let ratingsShown = false;
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={["rating", minimal ? (!rating ? "minimal" : "minimal-space") : ""].join(" ")}
|
||||
class={[
|
||||
"rating",
|
||||
minimal ? (!rating ? "minimal" : "minimal-space") : "",
|
||||
disableInteraction ? "interaction-disabled" : ""
|
||||
].join(" ")}
|
||||
on:click={(ev) => {
|
||||
ev.stopPropagation();
|
||||
ratingsShown = !ratingsShown;
|
||||
@@ -23,7 +28,7 @@
|
||||
}}
|
||||
use:tooltip={{ text: btnTooltip, pos: "top", condition: !!btnTooltip && !ratingsShown }}
|
||||
>
|
||||
<span>*</span>
|
||||
<span style={hideStarWhenRated && rating ? "display: none" : ""}>*</span>
|
||||
{#if !minimal}
|
||||
<span class={!rating && disableInteraction ? "unrated-text" : ""}>
|
||||
{rating ? rating : disableInteraction ? "Unrated" : "Rate"}
|
||||
@@ -59,6 +64,25 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&.interaction-disabled {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
background-color: transparent;
|
||||
border: unset;
|
||||
fill: white;
|
||||
color: white;
|
||||
|
||||
span {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.unrated-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 15px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.minimal span:first-child {
|
||||
letter-spacing: unset;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { WatchedStatus } from "@/types";
|
||||
import Icon from "../Icon.svelte";
|
||||
import tooltip from "../actions/tooltip";
|
||||
import { watchedStatuses } from "../util/helpers";
|
||||
import { toUnderstandableStatus, watchedStatuses } from "../util/helpers";
|
||||
|
||||
export let status: WatchedStatus | undefined = undefined;
|
||||
export let handleStatusClick: (status: WatchedStatus | "DELETE") => void;
|
||||
@@ -10,12 +10,14 @@
|
||||
export let width = "40%";
|
||||
export let small = false;
|
||||
export let btnTooltip: string = "";
|
||||
export let disableInteraction: boolean = false;
|
||||
export let isForGame = false;
|
||||
|
||||
let statusesShown = false;
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="status"
|
||||
class={["status", disableInteraction ? "interaction-disabled" : ""].join(" ")}
|
||||
style={`width: ${width};`}
|
||||
on:click={(ev) => {
|
||||
ev.stopPropagation();
|
||||
@@ -38,7 +40,9 @@
|
||||
<button
|
||||
class="plain{status && status !== statusName ? ' not-active' : ''}"
|
||||
on:click={() => handleStatusClick(statusName)}
|
||||
use:tooltip={{ text: statusName }}
|
||||
use:tooltip={{
|
||||
text: toUnderstandableStatus(statusName, isForGame)
|
||||
}}
|
||||
>
|
||||
<Icon i={icon} />
|
||||
</button>
|
||||
@@ -63,6 +67,19 @@
|
||||
font-family: "Rampart One";
|
||||
height: 100%;
|
||||
|
||||
&.interaction-disabled {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
background-color: transparent;
|
||||
border: unset;
|
||||
fill: white;
|
||||
color: white;
|
||||
|
||||
span {
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
.no-icon {
|
||||
color: $text-color;
|
||||
font-size: 30px;
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
h3 {
|
||||
font-variant: small-caps;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -37,11 +33,6 @@
|
||||
}
|
||||
|
||||
div {
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
&.row {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
@@ -51,10 +42,6 @@
|
||||
& > div:first-of-type {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
&.btns button {
|
||||
width: min-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import tooltip from "../actions/tooltip";
|
||||
|
||||
export let name: string;
|
||||
export let value: string | number;
|
||||
export let large = false;
|
||||
export let href: string | undefined = undefined;
|
||||
export let disc: string | undefined = undefined;
|
||||
</script>
|
||||
|
||||
<a {href}>
|
||||
{#if disc}
|
||||
<div class="disclaimer" use:tooltip={{ text: disc, pos: "top" }}>*</div>
|
||||
{/if}
|
||||
<span class={large ? "large" : ""}>{value}</span>
|
||||
<span>{name}</span>
|
||||
</a>
|
||||
|
||||
<style lang="scss">
|
||||
a {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
flex-grow: 1;
|
||||
flex: 1 1 115px;
|
||||
padding: 20px 15px;
|
||||
background-color: $accent-color;
|
||||
border-radius: 8px;
|
||||
|
||||
> span:first-of-type {
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
|
||||
&.large {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
> span:last-child {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 8px;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 12px;
|
||||
margin-top: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { serverFeatures, userInfo, userSettings, watchedList } from "@/store";
|
||||
import { follows, serverFeatures, userInfo, userSettings, watchedList } from "@/store";
|
||||
import {
|
||||
UserType,
|
||||
type JellyfinFoundContent,
|
||||
@@ -8,19 +8,64 @@ import {
|
||||
type WatchedStatus,
|
||||
type WatchedUpdateRequest,
|
||||
type WatchedUpdateResponse,
|
||||
type UserSettings
|
||||
type UserSettings,
|
||||
type Follow,
|
||||
type PlayedAddRequest,
|
||||
type ActivityUpdateRequest
|
||||
} from "@/types";
|
||||
import axios from "axios";
|
||||
import { get } from "svelte/store";
|
||||
import { notify } from "./notify";
|
||||
import { notify, unNotify } from "./notify";
|
||||
const { MODE } = import.meta.env;
|
||||
|
||||
export const baseURL = MODE === "development" ? "http://127.0.0.1:3080/api" : "/api";
|
||||
|
||||
/**
|
||||
*
|
||||
* Updates watched item with new status, rating or thoughts.
|
||||
*/
|
||||
function _updateWatched(
|
||||
wEntry: Watched,
|
||||
status?: WatchedStatus,
|
||||
rating?: number,
|
||||
thoughts?: string
|
||||
) {
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
if (!status && !rating && typeof thoughts === "undefined") return;
|
||||
const obj = {} as WatchedUpdateRequest;
|
||||
if (status) obj.status = status;
|
||||
if (rating) obj.rating = rating;
|
||||
if (typeof thoughts !== "undefined") obj.thoughts = thoughts;
|
||||
if (thoughts === "") obj.removeThoughts = true;
|
||||
axios
|
||||
.put<WatchedUpdateResponse>(`/watched/${wEntry.id}`, obj)
|
||||
.then((resp) => {
|
||||
if (status) wEntry.status = status;
|
||||
if (rating) wEntry.rating = rating;
|
||||
if (typeof thoughts !== "undefined") wEntry.thoughts = thoughts;
|
||||
if (resp?.data?.newActivity) {
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(resp.data.newActivity);
|
||||
} else {
|
||||
wEntry.activity = [resp.data.newActivity];
|
||||
}
|
||||
// We want to update the updatedAt field too (so
|
||||
// change is reflected when filtering modified at)
|
||||
// We can piggy back from this data for now.
|
||||
wEntry.updatedAt = resp.data.newActivity.createdAt;
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
notify({ id: nid, text: `Saved!`, type: "success" });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Update!", type: "error" });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update watched show/movie.
|
||||
* @param contentId TMDB ID
|
||||
* @param contentType
|
||||
* @param contentType show/movie
|
||||
* @param status
|
||||
* @param rating
|
||||
* @returns
|
||||
@@ -35,40 +80,10 @@ export function updateWatched(
|
||||
// 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
|
||||
(w) => w.content?.tmdbId === contentId && w.content?.type === contentType
|
||||
);
|
||||
if (wEntry?.id) {
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
if (!status && !rating && typeof thoughts === "undefined") return;
|
||||
const obj = {} as WatchedUpdateRequest;
|
||||
if (status) obj.status = status;
|
||||
if (rating) obj.rating = rating;
|
||||
if (typeof thoughts !== "undefined") obj.thoughts = thoughts;
|
||||
if (thoughts === "") obj.removeThoughts = true;
|
||||
axios
|
||||
.put<WatchedUpdateResponse>(`/watched/${wEntry.id}`, obj)
|
||||
.then((resp) => {
|
||||
if (status) wEntry.status = status;
|
||||
if (rating) wEntry.rating = rating;
|
||||
if (typeof thoughts !== "undefined") wEntry.thoughts = thoughts;
|
||||
if (resp?.data?.newActivity) {
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(resp.data.newActivity);
|
||||
} else {
|
||||
wEntry.activity = [resp.data.newActivity];
|
||||
}
|
||||
// We want to update the updatedAt field too (so
|
||||
// change is reflected when filtering modified at)
|
||||
// We can piggy back from this data for now.
|
||||
wEntry.updatedAt = resp.data.newActivity.createdAt;
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
notify({ id: nid, text: `Saved!`, type: "success" });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Update!", type: "error" });
|
||||
});
|
||||
_updateWatched(wEntry, status, rating, thoughts);
|
||||
return;
|
||||
}
|
||||
// Add new watched item
|
||||
@@ -119,6 +134,88 @@ export function removeWatched(id: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePlayed(
|
||||
igdbId: number,
|
||||
status?: WatchedStatus,
|
||||
rating?: number,
|
||||
thoughts?: string
|
||||
) {
|
||||
// If item is already in watched store, run update request instead
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find((w) => w.game?.igdbId === igdbId);
|
||||
if (wEntry?.id) {
|
||||
_updateWatched(wEntry, status, rating, thoughts);
|
||||
return;
|
||||
}
|
||||
// Add new played item
|
||||
const nid = notify({ text: `Adding`, type: "loading" });
|
||||
axios
|
||||
.post("/game/played", {
|
||||
igdbId,
|
||||
rating,
|
||||
status
|
||||
} as PlayedAddRequest)
|
||||
.then((resp) => {
|
||||
console.log("Added watched(played) game:", resp.data);
|
||||
wList.push(resp.data as Watched);
|
||||
watchedList.update(() => wList);
|
||||
notify({ id: nid, text: `Added!`, type: "success" });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Add!", type: "error" });
|
||||
});
|
||||
}
|
||||
|
||||
export function updateActivity(watchedId: number, activityId: number, date: Date) {
|
||||
const nid = notify({ text: "Updating", type: "loading" });
|
||||
console.debug("updateActivity called", watchedId, activityId, date);
|
||||
try {
|
||||
axios
|
||||
.put("/activity/" + activityId, {
|
||||
customDate: date.toISOString()
|
||||
} as ActivityUpdateRequest)
|
||||
.then((resp) => {
|
||||
console.log("Updated activity timestamp:", resp.status);
|
||||
const wList = get(watchedList);
|
||||
const activity = wList
|
||||
.find((w) => w.id === watchedId)
|
||||
?.activity.find((a) => a.id === activityId);
|
||||
if (activity) {
|
||||
activity.customDate = date.toISOString();
|
||||
watchedList.update(() => wList);
|
||||
}
|
||||
notify({ id: nid, text: "Updated!", type: "success" });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed to Update!", type: "error" });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("updateActivity failed!", err);
|
||||
notify({ id: nid, text: "Failed!", type: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
export function removeActivity(watchedId: number, activityId: number) {
|
||||
const nid = notify({ text: "Deleting", type: "loading" });
|
||||
axios
|
||||
.delete("/activity/" + activityId)
|
||||
.then((resp) => {
|
||||
const wList = get(watchedList);
|
||||
const wListItem = wList.find((w) => w.id === watchedId);
|
||||
if (wListItem) {
|
||||
wListItem.activity = wListItem.activity.filter((i) => i.id !== activityId);
|
||||
watchedList.update(() => wList);
|
||||
}
|
||||
notify({ id: nid, text: "Deleted!", type: "success" });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed to Delete!", type: "error" });
|
||||
});
|
||||
}
|
||||
|
||||
export async function contentExistsOnJellyfin(
|
||||
type: MediaType,
|
||||
name: string,
|
||||
@@ -169,6 +266,30 @@ export function updateUserSetting<K extends keyof UserSettings>(
|
||||
});
|
||||
}
|
||||
|
||||
export function changeUserPassword(
|
||||
oldPassword: string,
|
||||
newPassword: string,
|
||||
done?: (errMsg?: string) => void
|
||||
) {
|
||||
const nid = notify({ type: "loading", text: "Changing Password" });
|
||||
axios
|
||||
.post("/auth/change_password", { oldPassword, newPassword })
|
||||
.then((r) => {
|
||||
if (r.status === 200) {
|
||||
notify({ id: nid, type: "success", text: "Password Changed" });
|
||||
if (typeof done !== "undefined") done();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const errMsg = err?.response?.data?.error
|
||||
? err.response.data.error
|
||||
: "Couldn't Change Password";
|
||||
console.error("Change Password Form - Failed to change password on the server", err);
|
||||
unNotify(nid);
|
||||
if (typeof done !== "undefined") done(errMsg);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update serverFeatues store with fresh data.
|
||||
*/
|
||||
@@ -183,6 +304,39 @@ export async function getServerFeatures() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function followUser(id: number) {
|
||||
const nid = notify({ text: `Following`, type: "loading" });
|
||||
axios
|
||||
.post(`/follow/${id}`)
|
||||
.then((resp) => {
|
||||
console.log("Followed:", resp.data);
|
||||
const f = get(follows);
|
||||
f.push(resp.data as Follow);
|
||||
follows.update(() => f);
|
||||
notify({ id: nid, text: `Followed!`, type: "success" });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Follow!", type: "error" });
|
||||
});
|
||||
}
|
||||
|
||||
export async function unfollowUser(id: number) {
|
||||
const nid = notify({ text: `Unfollowing`, type: "loading" });
|
||||
axios
|
||||
.delete(`/follow/${id}`)
|
||||
.then((resp) => {
|
||||
console.log("Unfollowed:", resp.data);
|
||||
const f = get(follows);
|
||||
follows.update(() => f.filter((fo) => fo.followedUser.id != id));
|
||||
notify({ id: nid, text: `Unfollowed!`, type: "success" });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Unfollow!", type: "error" });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* For use with routes that don't require authentication (eg login/register)
|
||||
*/
|
||||
|
||||
@@ -57,9 +57,9 @@ export function isTouch() {
|
||||
// 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);
|
||||
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);
|
||||
console.log(wid, wtype, wel?.content?.title, wel?.status, wel?.rating);
|
||||
return {
|
||||
id: wel.id,
|
||||
status: wel.status,
|
||||
@@ -67,6 +67,48 @@ export function getWatchedDependedProps(wid: number, wtype: MediaType, list: Wat
|
||||
};
|
||||
}
|
||||
|
||||
export function getPlayedDependedProps(wid: number, list: Watched[]) {
|
||||
const wel = list.find((wl) => wl.game?.igdbId === wid);
|
||||
if (!wel) return {};
|
||||
return {
|
||||
id: wel.id,
|
||||
status: wel.status,
|
||||
rating: wel.rating
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a watched status into understandable text
|
||||
* depending on if the status is for a game or not.
|
||||
*/
|
||||
/**
|
||||
* Turns a WatchedStatus into readable and context aware text.
|
||||
* Watched statuses can be used normally for movies/tv, but
|
||||
* for games, we want to transform the status to make more sense.
|
||||
* ex: 'finished' would become 'played' for games, but remain
|
||||
* unmodified for series/movies.
|
||||
*
|
||||
* This is only for use when displaying a status in ui for a user
|
||||
* to read, should **never** be involved in logic (comparing
|
||||
* statuses for example).
|
||||
*
|
||||
* @param s The watched status.
|
||||
* @param isForGame If this status is being displayed for a game or not.
|
||||
*/
|
||||
export function toUnderstandableStatus(s: WatchedStatus, isForGame: boolean) {
|
||||
if (isForGame) {
|
||||
if (s === "FINISHED") {
|
||||
return "played";
|
||||
} else if (s === "WATCHING") {
|
||||
return "playing";
|
||||
}
|
||||
}
|
||||
if (s === "HOLD") {
|
||||
return "on hold";
|
||||
}
|
||||
return s?.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a class to the parent node of a clicked element.
|
||||
* @param e Event with currentTarget.
|
||||
@@ -188,3 +230,11 @@ export function userHasPermission(perms: UserPermission, reqPerm: UserPermission
|
||||
}
|
||||
return (perms & reqPerm) == reqPerm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes season and episode number to make a quickly understandable
|
||||
* string in this format S1E1.
|
||||
*/
|
||||
export function seasonAndEpToReadable(season: number | undefined, episode: number | undefined) {
|
||||
return `S${typeof season === "number" ? String(season) : "(unknown)"}E${typeof episode === "number" ? String(episode) : "(unknown)"}`;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,9 @@ export function notify(n: Notification) {
|
||||
notifs.push({ ...n });
|
||||
notifications.update(() => notifs);
|
||||
}
|
||||
if (n.type !== "loading") setTimeout(() => unNotify(n.id!), n.time ?? 2500);
|
||||
if (n.type !== "loading" && n.time !== Infinity) {
|
||||
setTimeout(() => unNotify(n.id!), n.time ?? 2500);
|
||||
}
|
||||
return n.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { noAuthAxios } from "./api";
|
||||
|
||||
interface Plex {
|
||||
win: Window;
|
||||
headers: Record<string, string>;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
interface PlexPin {
|
||||
id: number;
|
||||
code: string;
|
||||
}
|
||||
|
||||
//
|
||||
function uuidv4() {
|
||||
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c: any) =>
|
||||
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
|
||||
);
|
||||
}
|
||||
|
||||
export function preparePlexAuth(): Plex {
|
||||
// No point making a store for this.
|
||||
let clientId = localStorage.getItem("plex-cid");
|
||||
if (!clientId) {
|
||||
if (crypto.randomUUID) {
|
||||
console.log("preparePlexAuth: Using randomUUID");
|
||||
clientId = crypto.randomUUID();
|
||||
} else {
|
||||
// Use this if randomUUID is unavailable (ex in unsecure context, this use case isn't as big a deelio)
|
||||
console.log("preparePlexAuth: Not using randomUUID");
|
||||
clientId = uuidv4();
|
||||
}
|
||||
localStorage.setItem("plex-cid", clientId);
|
||||
}
|
||||
|
||||
const w = 600;
|
||||
const h = 800;
|
||||
// Fixes dual-screen position Most browsers Firefox
|
||||
const dualScreenLeft = window.screenLeft != undefined ? window.screenLeft : window.screenX;
|
||||
const dualScreenTop = window.screenTop != undefined ? window.screenTop : window.screenY;
|
||||
const width = window.innerWidth
|
||||
? window.innerWidth
|
||||
: document.documentElement.clientWidth
|
||||
? document.documentElement.clientWidth
|
||||
: screen.width;
|
||||
const height = window.innerHeight
|
||||
? window.innerHeight
|
||||
: document.documentElement.clientHeight
|
||||
? document.documentElement.clientHeight
|
||||
: screen.height;
|
||||
const left = width / 2 - w / 2 + dualScreenLeft;
|
||||
const top = height / 2 - h / 2 + dualScreenTop;
|
||||
|
||||
const win = window.open(
|
||||
"",
|
||||
"Continue to Watcharr With Plex",
|
||||
`scrollbars=yes, width=${w}, height=${h}, top=${top}, left=${left}`
|
||||
);
|
||||
if (win) {
|
||||
win.focus();
|
||||
return {
|
||||
win,
|
||||
// Don't really want to give all the possible headers,
|
||||
// trying to minimize it to what gets it working.
|
||||
headers: {
|
||||
"X-Plex-Product": "Watcharr",
|
||||
"X-Plex-Client-Identifier": clientId,
|
||||
"X-Plex-Version": "Plex OAuth",
|
||||
"X-Plex-Model": "Plex OAuth",
|
||||
"X-Plex-Platform": "Firefox",
|
||||
"X-Plex-Platform-Version": "123.0",
|
||||
"X-Plex-Device": "Windows",
|
||||
"X-Plex-Device-Name": "Watcharr"
|
||||
},
|
||||
clientId
|
||||
};
|
||||
}
|
||||
throw new Error("Failed to prepare popup!");
|
||||
}
|
||||
|
||||
export async function doPlexLogin({ win, headers, clientId }: Plex): Promise<PlexPin> {
|
||||
const pin = await getPlexPin(headers);
|
||||
win.location.href = `https://app.plex.tv/auth/#!?clientID=${clientId}&code=${pin.code}&context=Watcharr&context[device][device]=${headers["X-Plex-Device"]}&context[device][deviceName]=${headers["X-Plex-Device-Name"]}&context[device][platform]=${headers["X-Plex-Platform"]}&context[device][platformVersion]=${headers["X-Plex-Platform-Version"]}&context[device][product]=${headers["X-Plex-Product"]}`;
|
||||
return pin;
|
||||
}
|
||||
|
||||
export async function plexPinPoll(
|
||||
pin: PlexPin,
|
||||
{ win, headers }: Plex,
|
||||
done: (err?: Error, token?: string) => void
|
||||
) {
|
||||
const doPoll = async () => {
|
||||
try {
|
||||
console.debug("plexPinPoll");
|
||||
const r = await noAuthAxios.get(`https://plex.tv/api/v2/pins/${pin.id}`, {
|
||||
headers: { ...headers, code: pin.code }
|
||||
});
|
||||
if (r.data?.authToken) {
|
||||
done(undefined, r.data.authToken);
|
||||
win.close();
|
||||
} else if (win?.closed) {
|
||||
done(new Error("Plex popup closed before login completed"));
|
||||
} else {
|
||||
setTimeout(doPoll, 1000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("plexPinPoll: Failed!", err);
|
||||
done(new Error("Pin poll failed"));
|
||||
}
|
||||
};
|
||||
setTimeout(doPoll, 1000);
|
||||
}
|
||||
|
||||
async function getPlexPin(headers: Record<string, string>): Promise<PlexPin> {
|
||||
const r = await noAuthAxios.post(`https://plex.tv/api/v2/pins?strong=true`, undefined, {
|
||||
headers: headers
|
||||
});
|
||||
console.debug("getPlexPin:", r.data);
|
||||
return { id: r.data.id, code: r.data.code };
|
||||
}
|
||||
@@ -56,12 +56,16 @@
|
||||
}
|
||||
|
||||
:global(input, textarea) {
|
||||
padding: 5px 10px;
|
||||
padding: 7px 10px;
|
||||
border: 2px solid black;
|
||||
border-radius: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(input.invalid) {
|
||||
border: 3px solid $error;
|
||||
}
|
||||
|
||||
:global(textarea) {
|
||||
padding: 5px;
|
||||
max-width: 100%;
|
||||
@@ -79,7 +83,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px 10px;
|
||||
padding: 7px 10px;
|
||||
border: 2px solid $text-color;
|
||||
border-radius: 5px;
|
||||
background-color: $bg-color;
|
||||
@@ -137,7 +141,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
:global(button.plain) {
|
||||
:global(button.plain),
|
||||
:global(a.plain-btn) {
|
||||
background-color: transparent;
|
||||
color: $text-color;
|
||||
border: 0;
|
||||
@@ -155,6 +160,7 @@
|
||||
color: white;
|
||||
border: 1px solid white;
|
||||
border-radius: 6px;
|
||||
text-transform: capitalize;
|
||||
z-index: 99999;
|
||||
transition:
|
||||
top 100ms ease,
|
||||
@@ -262,3 +268,68 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(div.menu) {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 55px;
|
||||
width: 125px;
|
||||
border: 3px solid $text-color;
|
||||
border-radius: 10px;
|
||||
background-color: $bg-color;
|
||||
list-style: none;
|
||||
z-index: 50;
|
||||
|
||||
// The lil arrow, adjust its pos under each specific menu.
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
width: 0;
|
||||
border-top: 10px solid transparent;
|
||||
border-left: 10px solid transparent;
|
||||
border-right: 10px solid transparent;
|
||||
border-bottom: 10px solid $text-color;
|
||||
}
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
max-height: calc(100dvh - 65px);
|
||||
overflow: auto;
|
||||
|
||||
h5 {
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
font-size: 14px;
|
||||
padding: 8px 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 200ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: $text-color;
|
||||
color: $bg-color;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: gray;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,18 @@
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import tooltip from "@/lib/actions/tooltip";
|
||||
import DetailedMenu from "@/lib/nav/DetailedMenu.svelte";
|
||||
import FilterMenu from "@/lib/nav/FilterMenu.svelte";
|
||||
import FollowingMenu from "@/lib/nav/FollowingMenu.svelte";
|
||||
import SortMenu from "@/lib/nav/SortMenu.svelte";
|
||||
import { isTouch, parseTokenPayload, userHasPermission } from "@/lib/util/helpers";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import {
|
||||
activeFilters,
|
||||
activeSort,
|
||||
clearAllStores,
|
||||
defaultSort,
|
||||
follows,
|
||||
searchQuery,
|
||||
serverFeatures,
|
||||
userInfo,
|
||||
@@ -23,13 +29,13 @@
|
||||
import { get } from "svelte/store";
|
||||
|
||||
let navEl: HTMLElement;
|
||||
let searchTimeout: number;
|
||||
let searchTimeout: NodeJS.Timeout;
|
||||
let subMenuShown = false;
|
||||
let filterMenuShown = false;
|
||||
let sortMenuShown = false;
|
||||
let followingMenuShown = false;
|
||||
let detailedMenuShown = false;
|
||||
|
||||
$: sort = $activeSort;
|
||||
$: filter = $activeFilters;
|
||||
$: settings = $userSettings;
|
||||
$: user = $userInfo;
|
||||
|
||||
@@ -37,9 +43,8 @@
|
||||
if (!localStorage.getItem("token")) {
|
||||
goto("/login");
|
||||
} else {
|
||||
closeAllSubMenus("sub");
|
||||
subMenuShown = !subMenuShown;
|
||||
filterMenuShown = false;
|
||||
sortMenuShown = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,11 +124,12 @@
|
||||
|
||||
async function getInitialData() {
|
||||
if (localStorage.getItem("token")) {
|
||||
const [w, u, s, f] = await Promise.all([
|
||||
const [w, u, s, f, fo] = await Promise.all([
|
||||
axios.get("/watched"),
|
||||
axios.get("/user"),
|
||||
axios.get("/user/settings"),
|
||||
axios.get("/features")
|
||||
axios.get("/features"),
|
||||
axios.get("/follow")
|
||||
]);
|
||||
if (w?.data?.length > 0) {
|
||||
watchedList.update((wl) => (wl = w.data));
|
||||
@@ -137,45 +143,20 @@
|
||||
if (f?.data) {
|
||||
serverFeatures.update((sf) => (sf = f.data));
|
||||
}
|
||||
if (fo?.data) {
|
||||
follows.update((f) => (f = fo.data));
|
||||
}
|
||||
} else {
|
||||
goto("/login?again=1");
|
||||
}
|
||||
}
|
||||
|
||||
function sortClicked(type: string, modeType: string = "UPDOWN") {
|
||||
const af = get(activeSort);
|
||||
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;
|
||||
}
|
||||
activeSort.update((af) => (af = [type, mode]));
|
||||
}
|
||||
|
||||
function filterClicked(type: keyof Filters, f: string) {
|
||||
const af = get(activeFilters);
|
||||
if (af[type]?.includes(f)) {
|
||||
af[type] = af[type]?.filter((a) => a !== f);
|
||||
} else {
|
||||
af[type]?.push(f);
|
||||
}
|
||||
activeFilters.update((a) => (a = af));
|
||||
function closeAllSubMenus(except?: string) {
|
||||
if (except !== "sub") subMenuShown = false;
|
||||
if (except !== "filter") filterMenuShown = false;
|
||||
if (except !== "sort") sortMenuShown = false;
|
||||
if (except !== "following") followingMenuShown = false;
|
||||
if (except !== "detailed") detailedMenuShown = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -188,9 +169,7 @@
|
||||
} else {
|
||||
navEl?.classList.add("scrolled-down");
|
||||
document.body.classList.remove("nav-shown");
|
||||
subMenuShown = false;
|
||||
filterMenuShown = false;
|
||||
sortMenuShown = false;
|
||||
closeAllSubMenus();
|
||||
}
|
||||
scroll = window.scrollY;
|
||||
});
|
||||
@@ -207,107 +186,59 @@
|
||||
</a>
|
||||
<input type="text" placeholder="Search" bind:value={$searchQuery} on:keydown={handleSearch} />
|
||||
<div class="btns">
|
||||
<!-- Only show on watched list -->
|
||||
<!-- Detailed posters only supported on own watched list currently -->
|
||||
{#if $page.url?.pathname === "/"}
|
||||
<button
|
||||
class="plain other detailedView"
|
||||
on:click={() => {
|
||||
closeAllSubMenus("detailed");
|
||||
detailedMenuShown = !detailedMenuShown;
|
||||
}}
|
||||
use:tooltip={{ text: "Detailed View", pos: "bot", condition: !detailedMenuShown }}
|
||||
>
|
||||
<Icon i="eye" />
|
||||
{#if $activeFilters?.type?.length > 0 || $activeFilters?.status?.length > 0}
|
||||
<div class="indicator"></div>
|
||||
{/if}
|
||||
</button>
|
||||
{#if detailedMenuShown}
|
||||
<DetailedMenu />
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- Show on watched list and shared/followed watched lists -->
|
||||
{#if $page.url?.pathname === "/" || $page.url?.pathname.includes("/lists/")}
|
||||
<button
|
||||
class="plain other filter"
|
||||
on:click={() => {
|
||||
closeAllSubMenus("filter");
|
||||
filterMenuShown = !filterMenuShown;
|
||||
sortMenuShown = false;
|
||||
subMenuShown = false;
|
||||
}}
|
||||
use:tooltip={{ text: "Filter", pos: "bot" }}
|
||||
use:tooltip={{ text: "Filter", pos: "bot", condition: !filterMenuShown }}
|
||||
>
|
||||
<Icon i="filter" />
|
||||
{#if $activeFilters?.type?.length > 0 || $activeFilters?.status?.length > 0}
|
||||
<div class="indicator"></div>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="plain other sort"
|
||||
on:click={() => {
|
||||
closeAllSubMenus("sort");
|
||||
sortMenuShown = !sortMenuShown;
|
||||
filterMenuShown = false;
|
||||
subMenuShown = false;
|
||||
}}
|
||||
use:tooltip={{ text: "Sort", pos: "bot" }}
|
||||
use:tooltip={{ text: "Sort", pos: "bot", condition: !sortMenuShown }}
|
||||
>
|
||||
<Icon i="sort" />
|
||||
<!-- Show indicator if not equal to default and second item in array is not falsy -->
|
||||
{#if $activeSort?.length === 2 && $activeSort[1] && JSON.stringify($activeSort) !== JSON.stringify(defaultSort)}
|
||||
<div class="indicator"></div>
|
||||
{/if}
|
||||
</button>
|
||||
{#if sortMenuShown}
|
||||
<div class="menu sort-menu">
|
||||
<button
|
||||
class={`plain ${sort[0] == "DATEADDED" ? sort[1].toLowerCase() : ""}`}
|
||||
on:click={() => sortClicked("DATEADDED")}
|
||||
>
|
||||
Date Added
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${sort[0] == "LASTCHANGED" ? sort[1].toLowerCase() : ""}`}
|
||||
on:click={() => sortClicked("LASTCHANGED")}
|
||||
>
|
||||
Last Changed
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${sort[0] == "RATING" ? sort[1].toLowerCase() : ""}`}
|
||||
on:click={() => sortClicked("RATING")}
|
||||
>
|
||||
Rating
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${sort[0] == "ALPHA" ? sort[1].toLowerCase() : ""}`}
|
||||
on:click={() => sortClicked("ALPHA")}
|
||||
>
|
||||
Alphabetical
|
||||
</button>
|
||||
</div>
|
||||
<SortMenu />
|
||||
{/if}
|
||||
{#if filterMenuShown}
|
||||
<div class="menu filter-menu">
|
||||
<h4 class="norm sm-caps">type</h4>
|
||||
<div class="type-filter">
|
||||
<button
|
||||
class={`${filter.type.includes("tv") ? "active" : ""}`}
|
||||
on:click={() => filterClicked("type", "tv")}
|
||||
>
|
||||
SHOW
|
||||
</button>
|
||||
<button
|
||||
class={`${filter.type.includes("movie") ? "active" : ""}`}
|
||||
on:click={() => filterClicked("type", "movie")}
|
||||
>
|
||||
MOVIE
|
||||
</button>
|
||||
</div>
|
||||
<h4 class="norm sm-caps">status</h4>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("planned") ? "on" : ""}`}
|
||||
on:click={() => filterClicked("status", "planned")}
|
||||
>
|
||||
planned
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("watching") ? "on" : ""}`}
|
||||
on:click={() => filterClicked("status", "watching")}
|
||||
>
|
||||
watching
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("finished") ? "on" : ""}`}
|
||||
on:click={() => filterClicked("status", "finished")}
|
||||
>
|
||||
finished
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("hold") ? "on" : ""}`}
|
||||
on:click={() => filterClicked("status", "hold")}
|
||||
>
|
||||
held
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("dropped") ? "on" : ""}`}
|
||||
on:click={() => filterClicked("status", "dropped")}
|
||||
>
|
||||
dropped
|
||||
</button>
|
||||
</div>
|
||||
<FilterMenu />
|
||||
{/if}
|
||||
{/if}
|
||||
<button
|
||||
@@ -315,24 +246,39 @@
|
||||
on:click={() => goto("/discover")}
|
||||
use:tooltip={{ text: "Discover", pos: "bot" }}
|
||||
>
|
||||
<Icon i="compass" />
|
||||
<Icon i="compass" wh={26} />
|
||||
</button>
|
||||
<button
|
||||
class="plain other following"
|
||||
on:click={() => {
|
||||
closeAllSubMenus("following");
|
||||
followingMenuShown = !followingMenuShown;
|
||||
}}
|
||||
use:tooltip={{ text: "Following", pos: "bot", condition: !followingMenuShown }}
|
||||
>
|
||||
<Icon i="people" wh={26} />
|
||||
</button>
|
||||
{#if followingMenuShown}
|
||||
<FollowingMenu close={() => (followingMenuShown = false)} />
|
||||
{/if}
|
||||
<button class="plain face" on:click={handleProfileClick}>:)</button>
|
||||
{#if subMenuShown}
|
||||
<div class="menu face-menu">
|
||||
{#if user?.username}
|
||||
<h5 title={user.username}>Hi {user.username}!</h5>
|
||||
{/if}
|
||||
<button class="plain" on:click={() => profile()}>Profile</button>
|
||||
{#if !settings?.private}
|
||||
<button class="plain" on:click={() => shareWatchedList()}>Share List</button>
|
||||
{/if}
|
||||
{#if user && userHasPermission(user.permissions, UserPermission.PERM_ADMIN)}
|
||||
<button class="plain" on:click={() => serverSettings()}>Settings</button>
|
||||
{/if}
|
||||
<button class="plain" on:click={() => logout()}>Logout</button>
|
||||
<!-- svelte-ignore missing-declaration -->
|
||||
<span>v{__WATCHARR_VERSION__}</span>
|
||||
<div>
|
||||
{#if user?.username}
|
||||
<h5 title={user.username}>Hi {user.username}!</h5>
|
||||
{/if}
|
||||
<button class="plain" on:click={() => profile()}>Profile</button>
|
||||
{#if !settings?.private}
|
||||
<button class="plain" on:click={() => shareWatchedList()}>Share List</button>
|
||||
{/if}
|
||||
{#if user && userHasPermission(user.permissions, UserPermission.PERM_ADMIN)}
|
||||
<button class="plain" on:click={() => serverSettings()}>Settings</button>
|
||||
{/if}
|
||||
<button class="plain" on:click={() => logout()}>Logout</button>
|
||||
<!-- svelte-ignore missing-declaration -->
|
||||
<span>v{__WATCHARR_VERSION__}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -395,7 +341,7 @@
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 580px) {
|
||||
@media screen and (max-width: 620px) {
|
||||
span.large {
|
||||
display: none;
|
||||
}
|
||||
@@ -445,6 +391,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
button.detailedView {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
button.filter {
|
||||
margin-right: 15px;
|
||||
&:hover,
|
||||
@@ -459,8 +409,23 @@
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
button.filter,
|
||||
button.sort {
|
||||
position: relative;
|
||||
|
||||
.indicator {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: -6px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: $text-color;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
button.discover {
|
||||
margin-right: 17px;
|
||||
margin-right: 12px;
|
||||
transition:
|
||||
fill 150ms ease,
|
||||
stroke 150ms ease,
|
||||
@@ -473,6 +438,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
button.following {
|
||||
margin-right: 17px;
|
||||
}
|
||||
|
||||
button.face {
|
||||
font-family:
|
||||
"Shrikhand",
|
||||
@@ -494,138 +463,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
div.menu {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
top: 55px;
|
||||
width: 125px;
|
||||
padding: 10px;
|
||||
border: 3px solid $text-color;
|
||||
border-radius: 10px;
|
||||
background-color: $bg-color;
|
||||
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,
|
||||
&:focus-visible {
|
||||
background-color: $text-color;
|
||||
color: $bg-color;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: gray;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
div.sort-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.filter-menu {
|
||||
width: 180px;
|
||||
right: 35px;
|
||||
|
||||
h4 {
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
& > button {
|
||||
text-transform: capitalize;
|
||||
position: relative;
|
||||
|
||||
&.on::before {
|
||||
content: "\2713";
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 12px;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.type-filter {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
width: 100%;
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
padding: 8px 0;
|
||||
width: 100%;
|
||||
|
||||
&:first-of-type {
|
||||
border-right: 0;
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
}
|
||||
div.face-menu {
|
||||
&:before {
|
||||
right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 580px) {
|
||||
@media screen and (max-width: 666px) {
|
||||
input {
|
||||
width: 100%;
|
||||
|
||||
&:focus + .btns button:not(.face) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
<script lang="ts">
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import PersonPoster from "@/lib/poster/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 { serverFeatures, watchedList } from "@/store";
|
||||
import { GameWebsiteCategory, type GameDetailsResponse, type WatchedStatus } from "@/types";
|
||||
import axios from "axios";
|
||||
import Activity from "@/lib/Activity.svelte";
|
||||
import Title from "@/lib/content/Title.svelte";
|
||||
import VideoEmbedModal from "@/lib/content/VideoEmbedModal.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import Error from "@/lib/Error.svelte";
|
||||
import FollowedThoughts from "@/lib/content/FollowedThoughts.svelte";
|
||||
import { updatePlayed } from "@/lib/util/api.js";
|
||||
import GamePoster from "@/lib/poster/GamePoster.svelte";
|
||||
import { getPlayedDependedProps } from "@/lib/util/helpers";
|
||||
|
||||
export let data;
|
||||
|
||||
let trailer: string | undefined;
|
||||
let trailerShown = false;
|
||||
|
||||
$: wList = $watchedList;
|
||||
$: wListItem = $watchedList.find((w) => w.game?.igdbId === data.gameId);
|
||||
|
||||
let gameId: number | undefined;
|
||||
let game: GameDetailsResponse | undefined;
|
||||
let pageError: Error | undefined;
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = page.subscribe((value) => {
|
||||
console.log(value);
|
||||
const params = value.params;
|
||||
if (params && params.id) {
|
||||
gameId = Number(params.id);
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
$: {
|
||||
(async () => {
|
||||
try {
|
||||
game = undefined;
|
||||
pageError = undefined;
|
||||
if (!gameId) {
|
||||
return;
|
||||
}
|
||||
const data = (await axios.get(`/game/${gameId}`)).data as GameDetailsResponse;
|
||||
if (data.videos?.length > 0) {
|
||||
const t = data.videos.find((v) => v.name?.toLowerCase() === "trailer");
|
||||
// Doc says the video_id is "usually youtube", so we are gonna go with that assumption too ( 0 _ 0 )
|
||||
if (t?.video_id) {
|
||||
trailer = `https://www.youtube.com/embed/${t?.video_id}`;
|
||||
}
|
||||
}
|
||||
game = data;
|
||||
} catch (err: any) {
|
||||
game = undefined;
|
||||
pageError = err;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function contentChanged(newStatus?: WatchedStatus, newRating?: number, newThoughts?: string) {
|
||||
if (!gameId) {
|
||||
console.error("contentChanged: no gameId");
|
||||
return;
|
||||
}
|
||||
updatePlayed(gameId, newStatus, newRating, newThoughts);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if pageError}
|
||||
<PageError pretty="Failed to load game!" error={pageError} />
|
||||
{:else if !game}
|
||||
<Spinner />
|
||||
{:else if Object.keys(game).length > 0}
|
||||
<div>
|
||||
<div class="content">
|
||||
{#if game?.artworks?.length > 0}
|
||||
<img
|
||||
class="backdrop"
|
||||
src={"https://images.igdb.com/igdb/image/upload/t_720p/" +
|
||||
game.artworks[Math.floor(Math.random() * game.artworks.length)].image_id +
|
||||
".jpg"}
|
||||
alt=""
|
||||
/>
|
||||
{:else if game?.cover?.image_id}
|
||||
<!-- Fallback to using the game cover for backdrop if there is no artwork -->
|
||||
<img
|
||||
class="backdrop"
|
||||
src={"https://images.igdb.com/igdb/image/upload/t_720p/" + game.cover.image_id + ".jpg"}
|
||||
alt=""
|
||||
/>
|
||||
{/if}
|
||||
<div class="vignette" />
|
||||
|
||||
<div class="details-container">
|
||||
<img
|
||||
class="poster"
|
||||
src={"https://images.igdb.com/igdb/image/upload/t_cover_big/" +
|
||||
game.cover.image_id +
|
||||
".jpg"}
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<div class="details">
|
||||
<Title
|
||||
title={game.name}
|
||||
homepage={game.websites?.find((w) => w.category == GameWebsiteCategory.Official)?.url}
|
||||
releaseYear={new Date(game.first_release_date).getFullYear()}
|
||||
voteAverage={game.rating}
|
||||
voteCount={game.rating_count}
|
||||
/>
|
||||
|
||||
<span class="quick-info">
|
||||
{#if game.genres?.length > 0}
|
||||
<div>
|
||||
{#each game.genres as g, i}
|
||||
<span>{g.name}{i !== game.genres.length - 1 ? ", " : ""}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span>Unknown Genres</span>
|
||||
{/if}
|
||||
<span></span>
|
||||
<div>
|
||||
{#if game.game_modes?.length > 0}
|
||||
{#each game.game_modes as g, i}
|
||||
<span>{g.name}{i !== game.game_modes.length - 1 ? ", " : ""}</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span>Unknown Game Modes</span>
|
||||
{/if}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<span style="font-weight: bold; font-size: 14px;">Overview</span>
|
||||
<p>{game.summary}</p>
|
||||
|
||||
<div class="btns">
|
||||
{#if trailer}
|
||||
<button on:click={() => (trailerShown = !trailerShown)}>View Trailer</button>
|
||||
{#if trailerShown}
|
||||
<VideoEmbedModal embed={trailer} closed={() => (trailerShown = false)} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- <ProvidersList providers={game["watch/providers"]} /> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<div class="review">
|
||||
<Rating rating={wListItem?.rating} onChange={(n) => contentChanged(undefined, n)} />
|
||||
<Status status={wListItem?.status} isForGame={true} onChange={(n) => contentChanged(n)} />
|
||||
{#if wListItem}
|
||||
<textarea
|
||||
name="Thoughts"
|
||||
rows="3"
|
||||
placeholder={`My thoughts on ${game.name}`}
|
||||
value={wListItem?.thoughts}
|
||||
on:blur={(e) => {
|
||||
if (wListItem?.thoughts === e.currentTarget.value) {
|
||||
// thoughts didn't change
|
||||
return;
|
||||
}
|
||||
contentChanged(undefined, undefined, e.currentTarget?.value);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if gameId}
|
||||
<FollowedThoughts mediaType="game" mediaId={gameId} />
|
||||
{/if}
|
||||
|
||||
{#if game.similar_games?.length > 0}
|
||||
<HorizontalList title="Similar">
|
||||
{#each game.similar_games as g}
|
||||
<GamePoster
|
||||
media={{
|
||||
id: g.id,
|
||||
coverId: g.cover.image_id,
|
||||
name: g.name,
|
||||
summary: g.summary,
|
||||
firstReleaseDate: g.first_release_date
|
||||
}}
|
||||
{...getPlayedDependedProps(g.id, wList)}
|
||||
small={true}
|
||||
/>
|
||||
{/each}
|
||||
</HorizontalList>
|
||||
{/if}
|
||||
|
||||
{#if wListItem}
|
||||
<Activity wListId={wListItem.id} activity={wListItem.activity} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Error error="Game not found" pretty="Game not found" />
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.content {
|
||||
position: relative;
|
||||
color: white;
|
||||
|
||||
img.backdrop {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: -2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: $backdrop-filter;
|
||||
mix-blend-mode: $backdrop-mix-blend-mode;
|
||||
mask-image: $backdrop-mask-image;
|
||||
}
|
||||
|
||||
.vignette {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba($color: #000000, $alpha: 0.7);
|
||||
z-index: -1;
|
||||
mask-image: $backdrop-mask-image;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
.quick-info {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btns {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
|
||||
a.btn,
|
||||
button {
|
||||
max-width: fit-content;
|
||||
overflow: hidden;
|
||||
animation: 50ms cubic-bezier(0.86, 0, 0.07, 1) forwards otherbtn;
|
||||
white-space: nowrap;
|
||||
gap: 6px;
|
||||
justify-content: flex-start;
|
||||
font-size: 14px;
|
||||
|
||||
@keyframes otherbtn {
|
||||
from {
|
||||
width: 0px;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 590px) {
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
gameId: Number(id)
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||