Compare commits
367 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 |
@@ -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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,3 @@ For big PRs (lots of changes/big change in the way something works), if you'd li
|
||||
## Project Board
|
||||
|
||||
We have a [board to organize new features and bug fixes](https://github.com/orgs/sbondCo/projects/9/views/2) to be worked on. If you see something you'd like to work on, just ask in the issue and it can be assigned to you. Even if it isn't set for the next milestone already, it can be.
|
||||
|
||||
|
||||
@@ -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.23.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,10 +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.
|
||||
@@ -21,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
|
||||
@@ -45,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
|
||||
@@ -93,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)
|
||||
}
|
||||
|
||||
@@ -142,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.
|
||||
@@ -149,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) {
|
||||
@@ -163,6 +187,32 @@ 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"`
|
||||
@@ -204,13 +254,13 @@ func getServerStats(db *gorm.DB) ServerStats {
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - MostWatchedShow query failed", "error", resp.Error)
|
||||
} else {
|
||||
stats.MostWatchedShow = w.Content
|
||||
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
|
||||
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"`
|
||||
@@ -94,7 +99,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
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
|
||||
@@ -122,7 +127,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
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
|
||||
@@ -237,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))
|
||||
|
||||
@@ -321,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
|
||||
@@ -346,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() {
|
||||
@@ -381,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() {
|
||||
@@ -416,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
|
||||
@@ -437,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,
|
||||
@@ -444,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() {
|
||||
@@ -489,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) {
|
||||
@@ -505,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() {
|
||||
@@ -559,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() {
|
||||
@@ -606,6 +941,22 @@ 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))
|
||||
@@ -827,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>
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
<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;
|
||||
@@ -20,7 +27,7 @@
|
||||
background-color: $accent-color;
|
||||
border-radius: 8px;
|
||||
|
||||
> span:first-child {
|
||||
> span:first-of-type {
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
|
||||
@@ -32,5 +39,13 @@
|
||||
> span:last-child {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 8px;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</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;
|
||||