Compare commits
311 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a5a470347 | |||
| 07e3f5f7a8 | |||
| 1b83c70b5e | |||
| 1e5cdc151b | |||
| dcb21a6e1e | |||
| 0b4f3d5c97 | |||
| 0d8ea359e4 | |||
| 1c44b1812a | |||
| 3d5cf70ec0 | |||
| f1f26d0f57 | |||
| e3fe047afd | |||
| 4afc32f9d1 | |||
| 7ea78f2d57 | |||
| 6092410b20 | |||
| 8f5293a769 | |||
| 6d25356d30 | |||
| dbd86b89c7 | |||
| bf0b4fbb6a | |||
| 5585053b85 | |||
| a61b82e9af | |||
| d4fe6d3653 | |||
| 6d2ab6e7e4 | |||
| c5e6ae702a | |||
| 2a9b0443f5 | |||
| e1e933af16 | |||
| a5fbecd32a | |||
| 98006efb03 | |||
| 926b0f8267 | |||
| cc3f21aa93 | |||
| e311bcd901 | |||
| eb66baba80 | |||
| 09795b4da3 | |||
| 8088184e6b | |||
| 1b6c56dd8f | |||
| 86195eb682 | |||
| f40ffa124c | |||
| 704b6380c7 | |||
| b434665caf | |||
| 3554d4bc60 | |||
| 917ddd5401 | |||
| e7cf21fdb2 | |||
| c932bd188a | |||
| e5d17bee81 | |||
| 65836a1654 | |||
| 0f7bdaffbb | |||
| 30dae4c5c9 | |||
| f67d1641db | |||
| 0ff02e00c9 | |||
| 194081ac2f | |||
| 896f80ebd6 | |||
| 862279e914 | |||
| bdf5cf54af | |||
| 4323ab7086 | |||
| 555b237ed0 | |||
| 5146af2672 | |||
| 4d3da51ae7 | |||
| 9cba877fe6 | |||
| 924ffcb33f | |||
| b638062d36 | |||
| da64b5ca59 | |||
| 8f2d9b0984 | |||
| c2e85ff8b3 | |||
| 3c9d074169 | |||
| 7a254222a1 | |||
| 4662977677 | |||
| 798c80f69b | |||
| d8e4182824 | |||
| ac9fb8c28d | |||
| d0b1b9ed17 | |||
| 548643327d | |||
| a86d2a218a | |||
| 21ecc4cbbf | |||
| 2e19cad3a8 | |||
| 8bffc7380e | |||
| 96720caace | |||
| 6c130952d7 | |||
| 3c1fc0e950 | |||
| d95990fc56 | |||
| 9d0a433121 | |||
| 2bd02439c2 | |||
| 6a1fdd69e3 | |||
| fb7dc5d0f8 | |||
| c3d480d47b | |||
| 274c97bcdd | |||
| d6b5fc9cd3 | |||
| 4de9865a39 | |||
| d278f2cf98 | |||
| 5aec83c2f0 | |||
| fbda3d4b9b | |||
| dde8fad248 | |||
| e8309568bc | |||
| 997e3afb7e | |||
| 891fc51bcd | |||
| ebe42f1d34 | |||
| b799c660ca | |||
| a10f570983 | |||
| f205f96010 | |||
| 735a98e0b2 | |||
| f87aa70a3b | |||
| a2dc418ee6 | |||
| 4c7982f7be | |||
| 43d603c212 | |||
| ebf623ed1b | |||
| d925519beb | |||
| 07b72452e8 | |||
| ab4781bec0 | |||
| 7719b6336c | |||
| e749e94bfb | |||
| c1ceb17ada | |||
| 91dd00b556 | |||
| 60e873b4c0 | |||
| 4333591c9c | |||
| 462a46aabb | |||
| c766d68d49 | |||
| 6b7c38aa7a | |||
| 367c79f29c | |||
| c59eb82a1a | |||
| 3d40e5554f | |||
| 75f091cfa0 | |||
| 1d0abe4982 | |||
| f8490481a5 | |||
| 1adc56f965 | |||
| 8861233aaa | |||
| 3c4718bc45 | |||
| f6fa9e7666 | |||
| 98a7c370a8 | |||
| 37f9bffb13 | |||
| a4573fe52a | |||
| aeaaccda88 | |||
| 3a2ce0c718 | |||
| 2ac80d0b8c | |||
| 3ff5d9836b | |||
| e6d486c2a2 | |||
| c4e591e2f2 | |||
| 0eb1a88103 | |||
| ffe6afe2d2 | |||
| c767572905 | |||
| da803220e4 | |||
| 5c4f023fd8 | |||
| 7e2e64f394 | |||
| 9609909122 | |||
| 7522784f18 | |||
| 8d5379e38b | |||
| 8065349a5c | |||
| 26c84ae7c7 | |||
| f190bffdd0 | |||
| e0f8d753bf | |||
| b6223c7ac8 | |||
| ffd8bbe442 | |||
| bc86b8f6b4 | |||
| 0cb5c01b5e | |||
| 0393966ecc | |||
| b3ba00cd0d | |||
| 5084a5b724 | |||
| 43a484f949 | |||
| 8434e852ce | |||
| e541dc5cdb | |||
| 4a20819803 | |||
| 9a568b9da7 | |||
| 9176d97eee | |||
| 1cbc065b7b | |||
| b4865aa789 | |||
| 0c29927ef6 | |||
| 75e499a070 | |||
| fe589e7de5 | |||
| 9d7d6b0e3a | |||
| 5fb720cca4 | |||
| 2084b42098 | |||
| d26db7759f | |||
| 89311d4487 | |||
| 72dca29d69 | |||
| 36b3bde00e | |||
| 425f0da422 | |||
| 6773736113 | |||
| 51ec248b67 | |||
| 702084c83c | |||
| 2aa5f0c4fa | |||
| 359f5a372a | |||
| 03a8397498 | |||
| 034e6b10e7 | |||
| 8100c5f9a9 | |||
| 95c8f2d5b0 | |||
| be29170f6d | |||
| f02699b089 | |||
| 82b7a3d0f6 | |||
| 8608ad4fd6 | |||
| ab96107797 | |||
| a38291c9b9 | |||
| 67cfd03a1d | |||
| c4fa99b5aa | |||
| 1d904a7188 | |||
| be8cee5378 | |||
| 128a3d4cc6 | |||
| 610fb3d0c0 | |||
| 2dd01f296b | |||
| 6e118085a9 | |||
| 2f3400d4d7 | |||
| da787741d8 | |||
| 97bdf2f89c | |||
| a8c8431715 | |||
| 18e3e333c6 | |||
| f7ddb1e14b | |||
| e5c513161e | |||
| 55235839ff | |||
| e5536c5429 | |||
| 02fc0ccb94 | |||
| 83dcb85e5b | |||
| 155cd21b40 | |||
| f06da5b26e | |||
| e00bfa0187 | |||
| 4a008e4c2f | |||
| 8c84fe29f9 | |||
| 3bd03e4e97 | |||
| 4d65a16584 | |||
| 21b4e49aa0 | |||
| 1bcc521d3a | |||
| 9fa85e3191 | |||
| 55ff90a190 | |||
| 2bf7639ecf | |||
| 2aabd94311 | |||
| 17a61c0248 | |||
| a17c7a4ce5 | |||
| f31009fa5e | |||
| 559ac8945a | |||
| 1ce7f5a938 | |||
| d634956d76 | |||
| 3275ad6620 | |||
| 3ac9e94f89 | |||
| 9562b3fef0 | |||
| ae95329d06 | |||
| 1e81872876 | |||
| 90eb422c9d | |||
| 3bfcb741f1 | |||
| e04d72e565 | |||
| 99aa4e564a | |||
| eac5c5a91e | |||
| 82e9a4c0ea | |||
| 4dd494dc80 | |||
| 4422e83cd7 | |||
| eec6e00dac | |||
| 579736eb10 | |||
| 06fadeffa1 | |||
| 97c87ecbf0 | |||
| e55593d62b | |||
| 2a3b5c8746 | |||
| 52097df861 | |||
| 92637aef3d | |||
| 02f537c5bf | |||
| c63ab3cddf | |||
| cf7457a671 | |||
| 095cc21ea4 | |||
| 983f4efa95 | |||
| 4573d69bbf | |||
| 07cbcb4d41 | |||
| 71e31df2bf | |||
| f969c591c1 | |||
| 6d1e95ff5c | |||
| e7a6a888b1 | |||
| ab9fdddc14 | |||
| 6160e906df | |||
| dafac30df1 | |||
| e98b6a383d | |||
| 679168d88c | |||
| 8679cdf0c6 | |||
| 27e2e98ca3 | |||
| 3f90b2f0ca | |||
| dfa4e81dc2 | |||
| 3d3bfa50bb | |||
| f4a4b7c6d7 | |||
| db1b1bee65 | |||
| 53aa76f009 | |||
| 24fe7f4984 | |||
| 99d9d4866a | |||
| 7b0594a39b | |||
| 628dfef8d1 | |||
| a3e305478b | |||
| 0ca133f002 | |||
| 0586e91b31 | |||
| 3dbcc7b69f | |||
| 784b6f2559 | |||
| d61711ace3 | |||
| c520cd6be0 | |||
| 00a3b7041e | |||
| 6a69fbdab2 | |||
| deb30ebf03 | |||
| 985e5b3d39 | |||
| 2b3dcd57d3 | |||
| 4200b03736 | |||
| 7190a43604 | |||
| 7c77d9c536 | |||
| bd844b17c4 | |||
| 9bf3e6728c | |||
| 78542120da | |||
| 6148773965 | |||
| 1796bc9fb7 | |||
| 86c82181e1 | |||
| 51aec112b4 | |||
| bffb7960cc | |||
| 075b688605 | |||
| 6f10d221cf | |||
| 37fad5b8d3 | |||
| 8c544535c6 | |||
| 23d407fbd6 | |||
| 20ca9ff2be | |||
| 1b8a8ba491 | |||
| e7d93649c2 | |||
| 53b8b1d500 | |||
| 6ae214bd98 | |||
| 7f067c62a4 | |||
| 2e6bfc9985 | |||
| 33ea4cd3fe |
@@ -0,0 +1,5 @@
|
||||
<!-- Make sure your code is formatted by running `npm run format` or using prettier manually. -->
|
||||
|
||||
### Changes made
|
||||
|
||||
<!-- Describe changes made here. If changes are visual a screenshot could be useful! -->
|
||||
@@ -9,7 +9,7 @@ updates:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "ui"
|
||||
|
||||
|
||||
# Server
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/server"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Multi arch support doc/guide:
|
||||
# https://docs.docker.com/build/ci/github-actions/multi-platform/
|
||||
|
||||
name: Development Watcharr Image Release
|
||||
|
||||
on:
|
||||
@@ -18,6 +21,12 @@ jobs:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -30,6 +39,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.image }}:dev
|
||||
labels: ${{ env.image }}:${{ github.sha }}
|
||||
|
||||
@@ -20,6 +20,12 @@ jobs:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -38,6 +44,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Test PR Changes
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
tags-ignore:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
linter_check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Get dependencies
|
||||
run: npm i
|
||||
|
||||
- name: Check for linting/formatting errors
|
||||
run: npm run lint
|
||||
@@ -12,4 +12,4 @@
|
||||
"program": "${fileDirname}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ FROM golang:1.21-alpine AS server
|
||||
WORKDIR /server
|
||||
COPY server/*.go server/go.* ./
|
||||
COPY server/arr/*.go ./arr/
|
||||
COPY server/game/*.go ./game/
|
||||
|
||||
# Required so we can build with cgo
|
||||
RUN apk update && apk add --no-cache musl-dev gcc build-base
|
||||
|
||||
RUN go mod download && GOOS=linux CGO_ENABLED=1 go build -o ./watcharr
|
||||
# CGO_CFLAGS: https://github.com/mattn/go-sqlite3/issues/1164#issuecomment-1635253695
|
||||
RUN go mod download && GOOS=linux CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go build -o ./watcharr
|
||||
|
||||
# Frontend
|
||||
FROM node:20-alpine AS ui
|
||||
|
||||
@@ -28,20 +28,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>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module.exports = {
|
||||
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
|
||||
presets: [require.resolve("@docusaurus/core/lib/babel/preset")]
|
||||
};
|
||||
|
||||
@@ -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: 80 KiB |
|
After Width: | Height: | Size: 803 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 10 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 = {
|
||||
@@ -87,8 +88,8 @@ const config = {
|
||||
]
|
||||
},
|
||||
prism: {
|
||||
theme: lightCodeTheme
|
||||
// darkTheme: darkCodeTheme
|
||||
theme: lightTheme,
|
||||
darkTheme: darkTheme
|
||||
},
|
||||
colorMode: {
|
||||
defaultMode: "light",
|
||||
|
||||
@@ -14,16 +14,16 @@
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "2.4.3"
|
||||
"@docusaurus/module-type-aliases": "3.1.1"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -38,6 +38,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
|
||||
/*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "1.22.0",
|
||||
"version": "1.33.0",
|
||||
"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.0.8",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
||||
"@typescript-eslint/parser": "^6.20.0",
|
||||
"@vite-pwa/sveltekit": "^0.3.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
"prettier": "^3.2.4",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^4.2.8",
|
||||
"svelte-check": "^3.6.3",
|
||||
"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.0.12"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.1"
|
||||
"axios": "^1.6.5",
|
||||
"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,19 @@ 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_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).
|
||||
SEASON_ADDED ActivityType = "SEASON_ADDED"
|
||||
SEASON_REMOVED ActivityType = "SEASON_REMOVED"
|
||||
SEASON_RATING_CHANGED ActivityType = "SEASON_RATING_CHANGED"
|
||||
SEASON_STATUS_CHANGED ActivityType = "SEASON_STATUS_CHANGED"
|
||||
)
|
||||
|
||||
type Activity struct {
|
||||
@@ -35,12 +38,15 @@ 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"`
|
||||
}
|
||||
|
||||
func getActivity(db *gorm.DB, userId uint, watchedId uint) ([]Activity, error) {
|
||||
@@ -57,7 +63,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())
|
||||
|
||||
@@ -46,6 +46,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"`
|
||||
@@ -59,13 +62,28 @@ type User struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
// We use a separate struct for registration to avoid confusion
|
||||
@@ -111,6 +129,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 +146,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 +233,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 +250,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")
|
||||
}
|
||||
@@ -525,3 +552,38 @@ func hasPermission(perms int, reqPerm int) bool {
|
||||
}
|
||||
return (perms & reqPerm) == reqPerm
|
||||
}
|
||||
|
||||
func userChangePassword(db *gorm.DB, pwds UserPasswordUpdateRequest, userId uint) error {
|
||||
slog.Debug("userChangePassword request running", "user_id", userId)
|
||||
user := new(User)
|
||||
res := db.Where("id = ?", userId).Select("password").Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("userChangePassword failed - failed to retrieve user from database", "user_id", userId, "error", res.Error)
|
||||
return errors.New("failed to retrieve user")
|
||||
}
|
||||
slog.Debug("userChangePassword user found", "user_id", userId)
|
||||
match, err := compareHash(pwds.OldPassword, user.Password)
|
||||
if err != nil {
|
||||
slog.Error("userChangePassword failed - failed to compare passwords", "user_id", userId, "error", err)
|
||||
return errors.New("failed to compare passwords")
|
||||
}
|
||||
if !match {
|
||||
slog.Error("userChangePassword failed - current password hash doesn't match password hash in database", "user_id", userId, "error", err)
|
||||
return errors.New("current password provided doesn't match password in database")
|
||||
}
|
||||
slog.Debug("userChangePassword hash for current password matches hash in the database", "user_id", userId)
|
||||
slog.Debug("userChangePassword hashing new password", "user_id", userId)
|
||||
hash, err := hashPassword(pwds.NewPassword, GetPassArgonParams())
|
||||
if err != nil {
|
||||
slog.Error("userChangePassword failed - failed to hash new password", "user_id", userId, "error", err)
|
||||
return errors.New("failed to hash new password")
|
||||
}
|
||||
slog.Debug("userChangePassword new password hashed", "user_id", userId)
|
||||
if err := db.Model(&User{}).Where("id = ?", userId).Update("password", hash).Error; err != nil {
|
||||
slog.Error("userChangePassword failed - failed to update password in database", "user_id", userId, "error", err)
|
||||
return errors.New("failed to update password")
|
||||
} else {
|
||||
slog.Debug("userChangePassword password updated", "user_id", userId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,8 +6,14 @@ import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/game"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var DataPath = "./data"
|
||||
|
||||
type ServerConfig struct {
|
||||
// Used to sign JWT tokens. Make sure to make
|
||||
// it strong, just like a very long, complicated password.
|
||||
@@ -27,6 +33,7 @@ type ServerConfig struct {
|
||||
|
||||
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
|
||||
@@ -49,6 +56,10 @@ func (c *ServerConfig) GetSafe() ServerConfig {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +151,7 @@ func writeConfig() error {
|
||||
type ServerFeatures struct {
|
||||
Sonarr bool `json:"sonarr"`
|
||||
Radarr bool `json:"radarr"`
|
||||
Games bool `json:"games"`
|
||||
}
|
||||
|
||||
// Get enabled server functionality from Config.
|
||||
@@ -147,6 +159,9 @@ type ServerFeatures struct {
|
||||
// which btns should be shown, etc.
|
||||
func getEnabledFeatures(userPerms int) ServerFeatures {
|
||||
var f ServerFeatures
|
||||
if Config.TWITCH.ClientID != nil && Config.TWITCH.ClientSecret != nil {
|
||||
f.Games = true
|
||||
}
|
||||
// https://github.com/sbondCo/Watcharr/issues/211
|
||||
// Currently requesting permissions have not been setup, only admins for now.
|
||||
if !hasPermission(userPerms, PERM_ADMIN) {
|
||||
@@ -160,3 +175,81 @@ func getEnabledFeatures(userPerms int) ServerFeatures {
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func saveTwitchConfig(c game.IGDB) error {
|
||||
// If existing client id and secret are same.. just return here
|
||||
if (Config.TWITCH.ClientID != nil && c.ClientID != nil && Config.TWITCH.ClientSecret != nil && c.ClientSecret != nil) &&
|
||||
*Config.TWITCH.ClientID == *c.ClientID && *Config.TWITCH.ClientSecret == *c.ClientSecret {
|
||||
slog.Info("saveTwitchConfig: New ClientID and ClientSecret match old ClientID and ClientSecret.. ignoring request to update.")
|
||||
return nil
|
||||
}
|
||||
// Update our config
|
||||
Config.TWITCH.ClientID = c.ClientID
|
||||
Config.TWITCH.ClientSecret = c.ClientSecret
|
||||
Config.TWITCH.AccessToken = ""
|
||||
Config.TWITCH.AccessTokenExpires = time.Time{}
|
||||
// Try to init again
|
||||
err := Config.TWITCH.Init()
|
||||
if err != nil {
|
||||
slog.Error("saveTwitchConfig failed to initialize TWITCH", "error", err)
|
||||
return errors.New("initialization with credentials failed")
|
||||
}
|
||||
err = writeConfig()
|
||||
if err != nil {
|
||||
slog.Error("saveTwitchConfig failed to write config", "error", err)
|
||||
return errors.New("failed to save config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ServerStats struct {
|
||||
Users int64 `json:"users"`
|
||||
PrivateUsers int64 `json:"privateUsers"`
|
||||
WatchedMovies int64 `json:"watchedMovies"`
|
||||
WatchedShows int64 `json:"watchedShows"`
|
||||
WatchedSeasons int64 `json:"watchedSeasons"`
|
||||
MostWatchedMovie Content `json:"mostWatchedMovie"`
|
||||
MostWatchedShow Content `json:"mostWatchedShow"`
|
||||
Activities int64 `json:"activities"`
|
||||
}
|
||||
|
||||
// Collect and return server stats
|
||||
// I cant sql so this the best yall gettin
|
||||
func getServerStats(db *gorm.DB) ServerStats {
|
||||
stats := ServerStats{}
|
||||
resp := db.Model(&User{}).Count(&stats.Users).Where("private = 1").Count(&stats.PrivateUsers)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - Users query failed", "error", resp.Error)
|
||||
}
|
||||
resp = db.Model(&WatchedSeason{}).Count(&stats.WatchedSeasons)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - WatchedSeasons query failed", "error", resp.Error)
|
||||
}
|
||||
resp = db.Model(&Activity{}).Count(&stats.Activities)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - Activities query failed", "error", resp.Error)
|
||||
}
|
||||
resp = db.Joins("JOIN contents ON contents.id = watcheds.content_id AND contents.type = ?", "tv").Find(&Watched{}).Count(&stats.WatchedShows)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - WatchedShows query failed", "error", resp.Error)
|
||||
}
|
||||
resp = db.Joins("JOIN contents ON contents.id = watcheds.content_id AND contents.type = ?", "movie").Find(&Watched{}).Count(&stats.WatchedMovies)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - WatchedMovies query failed", "error", resp.Error)
|
||||
}
|
||||
|
||||
var w Watched
|
||||
resp = db.Model(&Watched{}).Select("content_id, COUNT(*) AS mag").Joins("JOIN contents ON contents.type = ? AND contents.id = watcheds.content_id", "tv").Group("content_id").Order("mag DESC").Preload("Content").First(&w)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - MostWatchedShow query failed", "error", resp.Error)
|
||||
} else {
|
||||
stats.MostWatchedShow = *w.Content
|
||||
}
|
||||
resp = db.Model(&Watched{}).Select("content_id, COUNT(*) AS mag").Joins("JOIN contents ON contents.type = ? AND contents.id = watcheds.content_id", "movie").Group("content_id").Order("mag DESC").Preload("Content").First(&w)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - MostWatchedMovie query failed", "error", resp.Error)
|
||||
} else {
|
||||
stats.MostWatchedMovie = *w.Content
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ package main
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ContentType string
|
||||
@@ -34,6 +38,138 @@ type Content struct {
|
||||
NumberOfSeasons uint32 `json:"numberOfSeasons"`
|
||||
}
|
||||
|
||||
// onlyUpdate - If we should only update existing row if exists, or false to create/update if not exist.
|
||||
func saveContent(db *gorm.DB, c *Content, onlyUpdate bool) error {
|
||||
slog.Info("Saving content to db", "id", c.TmdbID, "title", c.Title)
|
||||
if c.TmdbID == 0 || c.Title == "" || c.Type == "" {
|
||||
slog.Error("saveContent: content missing id, title or type!", "id", c.TmdbID, "title", c.Title, "type", c.Type)
|
||||
return errors.New("content missing id or title")
|
||||
}
|
||||
var res *gorm.DB
|
||||
if onlyUpdate {
|
||||
// We only want to update an existing row, if it exists.
|
||||
res = db.Model(&Content{}).Where("type = ? AND tmdb_id = ?", c.Type, c.TmdbID).Updates(c)
|
||||
if res.Error != nil {
|
||||
slog.Error("saveContent: Error updating content in database", "error", res.Error.Error())
|
||||
return errors.New("failed to update cached content in database")
|
||||
}
|
||||
} else {
|
||||
// On conflict, update existing row with details incase any were updated/missing.
|
||||
res = db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "tmdb_id"}, {Name: "type"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"title",
|
||||
"poster_path",
|
||||
"overview",
|
||||
"release_date",
|
||||
"popularity",
|
||||
"vote_average",
|
||||
"vote_count",
|
||||
"imdb_id",
|
||||
"status",
|
||||
"budget",
|
||||
"revenue",
|
||||
"runtime",
|
||||
"number_of_episodes",
|
||||
"number_of_seasons",
|
||||
}),
|
||||
}).Create(&c)
|
||||
if res.Error != nil {
|
||||
// Error if anything but unique contraint error
|
||||
if res.Error != gorm.ErrDuplicatedKey {
|
||||
slog.Error("saveContent: Error creating content in database", "error", res.Error.Error())
|
||||
return errors.New("failed to cache content in database")
|
||||
}
|
||||
}
|
||||
}
|
||||
// If row created, download the image
|
||||
if res.RowsAffected > 0 {
|
||||
slog.Debug("saveContent: Downloading poster.")
|
||||
err := download("https://image.tmdb.org/t/p/w500"+c.PosterPath, path.Join("./data/img", c.PosterPath))
|
||||
if err != nil {
|
||||
slog.Error("saveContent: Failed to download content image!", "error", err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cacheContentTv(db *gorm.DB, content TMDBShowDetails, onlyUpdate bool) (Content, error) {
|
||||
slog.Debug("cacheContentTv", "content", content)
|
||||
var (
|
||||
releaseDate time.Time
|
||||
runtime uint32
|
||||
)
|
||||
var dateFormat = "2006-01-02"
|
||||
releaseDate, err := time.Parse(dateFormat, content.FirstAirDate)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse tv release date", "error", err)
|
||||
}
|
||||
if len(content.EpisodeRunTime) > 0 {
|
||||
runtime = uint32(content.EpisodeRunTime[0])
|
||||
}
|
||||
|
||||
c := Content{
|
||||
TmdbID: content.ID,
|
||||
Title: content.Name,
|
||||
Overview: content.Overview,
|
||||
PosterPath: content.PosterPath,
|
||||
Type: SHOW,
|
||||
ReleaseDate: &releaseDate,
|
||||
Popularity: content.Popularity,
|
||||
VoteAverage: content.VoteAverage,
|
||||
VoteCount: content.VoteCount,
|
||||
Status: content.Status,
|
||||
Runtime: runtime,
|
||||
NumberOfEpisodes: content.NumberOfEpisodes,
|
||||
NumberOfSeasons: content.NumberOfSeasons,
|
||||
}
|
||||
|
||||
err = saveContent(db, &c, onlyUpdate)
|
||||
if err != nil {
|
||||
slog.Error("cacheContentTv: Failed to save content!", "error", err)
|
||||
return Content{}, errors.New("failed to save content")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func cacheContentMovie(db *gorm.DB, content TMDBMovieDetails, onlyUpdate bool) (Content, error) {
|
||||
var (
|
||||
releaseDate time.Time
|
||||
)
|
||||
var dateFormat = "2006-01-02"
|
||||
// Get details from movie/show response and fill out needed vars
|
||||
releaseDate, err := time.Parse(dateFormat, content.ReleaseDate)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse movie release date", "error", err)
|
||||
}
|
||||
|
||||
c := Content{
|
||||
TmdbID: content.ID,
|
||||
Title: content.Title,
|
||||
Overview: content.Overview,
|
||||
PosterPath: content.PosterPath,
|
||||
Type: MOVIE,
|
||||
ReleaseDate: &releaseDate,
|
||||
Popularity: content.Popularity,
|
||||
VoteAverage: content.VoteAverage,
|
||||
VoteCount: content.VoteCount,
|
||||
ImdbID: content.ImdbID,
|
||||
Status: content.Status,
|
||||
Budget: content.Budget,
|
||||
Revenue: content.Revenue,
|
||||
Runtime: content.Runtime,
|
||||
}
|
||||
|
||||
err = saveContent(db, &c, onlyUpdate)
|
||||
if err != nil {
|
||||
slog.Error("cacheContentMovie: Failed to save content!", "error", err)
|
||||
return Content{}, errors.New("failed to save content")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Getting only region needed from api is not a feature yet
|
||||
// https://trello.com/c/75tR4cpF/106-add-watch-provider-region-filtering
|
||||
// When it is, this can be removed for that instead.
|
||||
@@ -68,7 +204,7 @@ func searchContent(query string) (TMDBSearchMultiResponse, error) {
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func movieDetails(id string, country string, rParams map[string]string) (TMDBMovieDetails, error) {
|
||||
func movieDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBMovieDetails, error) {
|
||||
resp := new(TMDBMovieDetails)
|
||||
err := tmdbRequest("/movie/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
@@ -76,6 +212,7 @@ func movieDetails(id string, country string, rParams map[string]string) (TMDBMov
|
||||
return TMDBMovieDetails{}, errors.New("failed to complete movie details request")
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
go cacheContentMovie(db, *resp, true)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -89,7 +226,7 @@ func movieCredits(id string) (TMDBContentCredits, error) {
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func tvDetails(id string, country string, rParams map[string]string) (TMDBShowDetails, error) {
|
||||
func tvDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBShowDetails, error) {
|
||||
resp := new(TMDBShowDetails)
|
||||
err := tmdbRequest("/tv/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
@@ -97,6 +234,7 @@ func tvDetails(id string, country string, rParams map[string]string) (TMDBShowDe
|
||||
return TMDBShowDetails{}, errors.New("failed to complete tv details request")
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
go cacheContentTv(db, *resp, true)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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,15 +3,16 @@ 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.5.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.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.15.0
|
||||
golang.org/x/crypto v0.18.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/sqlite v1.5.4
|
||||
gorm.io/gorm v1.25.5
|
||||
gorm.io/gorm v1.25.6
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -31,6 +32,7 @@ require (
|
||||
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/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
||||
@@ -43,7 +45,7 @@ require (
|
||||
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
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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=
|
||||
@@ -18,30 +20,24 @@ 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.5.0 h1:DgGKV7DDoOn36DFkNtbHrjoRiT5ExCe+PC9/xp7aKvk=
|
||||
github.com/gin-contrib/cors v1.5.0/go.mod h1:TvU7MAZ3EwrPLI2ztzTt3tqgvBCq+wn8WpZmfADjupI=
|
||||
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/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-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
|
||||
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
|
||||
@@ -61,18 +57,12 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
|
||||
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/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/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
@@ -84,22 +74,18 @@ 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/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=
|
||||
@@ -109,53 +95,36 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
|
||||
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=
|
||||
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/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
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/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.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.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=
|
||||
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/gorm v1.25.6 h1:V92+vVda1wEISSOMtodHVRcUIOPYa2tgQtyF+DfFx+A=
|
||||
gorm.io/gorm v1.25.6/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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -25,12 +26,14 @@ type KeyValueRequest struct {
|
||||
type BaseRouter struct {
|
||||
db *gorm.DB
|
||||
rg *gin.RouterGroup
|
||||
ms *persistence.InMemoryStore
|
||||
}
|
||||
|
||||
func newBaseRouter(db *gorm.DB, rg *gin.RouterGroup) *BaseRouter {
|
||||
return &BaseRouter{
|
||||
db: db,
|
||||
rg: rg,
|
||||
ms: persistence.NewInMemoryStore(time.Hour * 24),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,10 +73,9 @@ func (b *BaseRouter) addSetupRoutes() {
|
||||
func (b *BaseRouter) addContentRoutes() {
|
||||
content := b.rg.Group("/content").Use(AuthRequired(nil))
|
||||
exp := time.Hour * 24
|
||||
store := persistence.NewInMemoryStore(exp)
|
||||
|
||||
// Search for content
|
||||
content.GET("/:query", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
// println(c.Param("query"))
|
||||
if c.Param("query") == "" {
|
||||
c.Status(400)
|
||||
@@ -88,12 +90,12 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get movie details (for movie page)
|
||||
content.Use(WhereaboutsRequired()).GET("/movie/:id", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.Use(WhereaboutsRequired()).GET("/movie/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := movieDetails(c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar"})
|
||||
content, err := movieDetails(b.db, c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -102,7 +104,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get movie cast
|
||||
content.GET("/movie/:id/credits", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/movie/:id/credits", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -116,12 +118,12 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get tv details (for tv page)
|
||||
content.Use(WhereaboutsRequired()).GET("/tv/:id", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.Use(WhereaboutsRequired()).GET("/tv/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := tvDetails(c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar,external_ids,keywords"})
|
||||
content, err := tvDetails(b.db, c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar,external_ids,keywords"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -130,7 +132,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get tv cast
|
||||
content.GET("/tv/:id/credits", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/tv/:id/credits", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -144,7 +146,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get season details
|
||||
content.GET("/tv/:id/season/:num", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/tv/:id/season/:num", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" || c.Param("num") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -158,7 +160,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get person details
|
||||
content.GET("/person/:id", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/person/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -172,7 +174,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get person credits
|
||||
content.GET("/person/:id/credits", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/person/:id/credits", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -186,7 +188,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Discover movies
|
||||
content.GET("/discover/movies", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/discover/movies", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content, err := discoverMovies()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -196,7 +198,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Discover shows
|
||||
content.GET("/discover/tv", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/discover/tv", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content, err := discoverTv()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -206,7 +208,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get all trending (movies, tv, people)
|
||||
content.GET("/trending", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/trending", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content, err := allTrending()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -216,7 +218,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Upcoming Movies
|
||||
content.GET("/upcoming/movies", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/upcoming/movies", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content, err := upcomingMovies()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -226,7 +228,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Upcoming Tv
|
||||
content.GET("/upcoming/tv", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content.GET("/upcoming/tv", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content, err := upcomingTv()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -236,6 +238,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))
|
||||
|
||||
@@ -443,33 +529,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() {
|
||||
@@ -558,6 +665,114 @@ func (b *BaseRouter) addUserRoutes() {
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Search users
|
||||
u.GET("/public/:pubUserId/:pubUsername", func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("pubUserId"))
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := getUserPublicInfo(b.db, uint(id), c.Param("pubUsername"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Update bio
|
||||
u.POST("/bio", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var br UserBioUpdateRequest
|
||||
err := c.ShouldBindJSON(&br)
|
||||
if err == nil {
|
||||
err := userUpdateBio(b.db, userId, br.NewBio)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// Upload avatar
|
||||
u.POST("/avatar", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := uploadUserAvatar(c, b.db, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addFollowRoutes() {
|
||||
f := b.rg.Group("/follow").Use(AuthRequired(b.db))
|
||||
|
||||
// Get users follows // TODO extend to support optionally passing user id as route param, default to current user
|
||||
f.GET("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := getFollows(b.db, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Follow a user
|
||||
f.POST("/:toFollowId", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
toFollowId, err := strconv.ParseUint(c.Param("toFollowId"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("failed to convert toFollowId param to uint", "toFollowId", toFollowId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := followUser(b.db, userId, uint(toFollowId))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Unfollow a user
|
||||
f.DELETE("/:toUnfollowId", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
toUnfollowId, err := strconv.ParseUint(c.Param("toUnfollowId"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("failed to convert toUnfollowId param to uint", "toUnfollowId", toUnfollowId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := unfollowUser(b.db, userId, uint(toUnfollowId))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Get follows thoughts on content
|
||||
f.GET("/thoughts/:type/:tmdbId", func(c *gin.Context) {
|
||||
t := c.Param("type")
|
||||
if t != "movie" && t != "tv" && t != "game" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "only movie, tv or game types are supported"})
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := getFollowsThoughts(b.db, userId, t, c.Param("tmdbId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addImportRoutes() {
|
||||
@@ -604,6 +819,11 @@ func (b *BaseRouter) addServerRoutes() {
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// Get server stats
|
||||
server.GET("/stats", cache.CachePage(b.ms, time.Minute*5, func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, getServerStats(b.db))
|
||||
}))
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addFeatureRoutes() {
|
||||
|
||||
@@ -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,22 @@ 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{},
|
||||
&Content{},
|
||||
&Watched{},
|
||||
&WatchedSeason{},
|
||||
&Activity{},
|
||||
&Token{},
|
||||
&Follow{},
|
||||
&Image{},
|
||||
&Game{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to auto migrate database:", err)
|
||||
}
|
||||
@@ -114,17 +125,19 @@ func main() {
|
||||
}
|
||||
br.addAuthRoutes()
|
||||
br.addContentRoutes()
|
||||
br.addGameRoutes()
|
||||
br.addWatchedRoutes()
|
||||
br.addActivityRoutes()
|
||||
br.addProfileRoutes()
|
||||
br.addJellyfinRoutes()
|
||||
br.addUserRoutes()
|
||||
br.addFollowRoutes()
|
||||
br.addImportRoutes()
|
||||
br.addServerRoutes()
|
||||
br.addFeatureRoutes()
|
||||
br.addSonarrRoutes()
|
||||
br.addRadarrRoutes()
|
||||
br.rg.Static("/img", "./data/img")
|
||||
br.rg.Static("/img", path.Join(DataPath, "img"))
|
||||
|
||||
go setupTasks(db)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -31,9 +30,11 @@ type Watched struct {
|
||||
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"`
|
||||
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
|
||||
}
|
||||
@@ -41,8 +42,12 @@ type Watched struct {
|
||||
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 +67,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").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
@@ -88,7 +93,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 +104,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 +116,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,12 +148,26 @@ 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() {
|
||||
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")
|
||||
@@ -241,6 +179,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType)
|
||||
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 +201,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -38,6 +38,18 @@
|
||||
return "thoughts removed";
|
||||
case "IMPORTED_WATCHED":
|
||||
return "imported";
|
||||
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":
|
||||
return "Imported Watch Date";
|
||||
case "SEASON_ADDED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
@@ -71,14 +83,22 @@
|
||||
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);
|
||||
@@ -99,9 +119,10 @@
|
||||
<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 title={d.toDateString()}>
|
||||
{toDayTime(d)}
|
||||
</span>
|
||||
<span>{getMsg(a)}</span>
|
||||
</li>
|
||||
|
||||
@@ -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>
|
||||
@@ -120,16 +120,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 +210,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,7 +2,7 @@
|
||||
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;
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
<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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -20,17 +20,27 @@
|
||||
{: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 isHidden}
|
||||
<button class="plain spoiler-text" on:click={() => (isHidden = false)}>
|
||||
<Icon i="eye-closed" wh={34} />
|
||||
@@ -66,6 +76,48 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
padding: 3px 5px;
|
||||
|
||||
@@ -74,24 +126,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 +159,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
img,
|
||||
.episode-name,
|
||||
img {
|
||||
.rating,
|
||||
.overview {
|
||||
transition: filter 150ms ease-out;
|
||||
}
|
||||
|
||||
&.dont-spoil {
|
||||
.episode-name {
|
||||
.episode-name,
|
||||
.rating,
|
||||
.overview {
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
@@ -148,5 +186,9 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -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,71 @@
|
||||
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, WatchedSeason } from "@/types";
|
||||
import GamePoster from "./poster/GamePoster.svelte";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
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 +77,125 @@
|
||||
}
|
||||
// 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 getLatestWatchedSeason(ws: WatchedSeason[] | undefined): string {
|
||||
if (!ws || ws.length <= 0) {
|
||||
return "";
|
||||
}
|
||||
let biggestSeasonWatched: number | undefined;
|
||||
let biggestSeasonWatching: number | undefined;
|
||||
for (let i = 0; i < ws.length; i++) {
|
||||
const s = ws[i];
|
||||
if (s.status === "WATCHING") {
|
||||
if (!biggestSeasonWatching) {
|
||||
biggestSeasonWatching = s.seasonNumber;
|
||||
continue;
|
||||
}
|
||||
if (s.seasonNumber > biggestSeasonWatching) {
|
||||
biggestSeasonWatching = s.seasonNumber;
|
||||
}
|
||||
} else if (s.status === "FINISHED") {
|
||||
if (!biggestSeasonWatched) {
|
||||
biggestSeasonWatched = s.seasonNumber;
|
||||
continue;
|
||||
}
|
||||
if (s.seasonNumber > biggestSeasonWatched) {
|
||||
biggestSeasonWatched = s.seasonNumber;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (biggestSeasonWatched === undefined && biggestSeasonWatching === undefined) return "";
|
||||
return `Season ${
|
||||
biggestSeasonWatching !== undefined ? biggestSeasonWatching : biggestSeasonWatched
|
||||
}`;
|
||||
}
|
||||
</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: getLatestWatchedSeason(w.watchedSeasons)
|
||||
}}
|
||||
/>
|
||||
{/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,
|
||||
|
||||
@@ -12,7 +12,11 @@
|
||||
</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;
|
||||
@@ -59,6 +63,25 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&.interaction-disabled {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
background-color: transparent;
|
||||
border: unset;
|
||||
fill: white;
|
||||
color: white;
|
||||
|
||||
span {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.unrated-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 15px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.minimal span:first-child {
|
||||
letter-spacing: unset;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { WatchedStatus } from "@/types";
|
||||
import Icon from "../Icon.svelte";
|
||||
import tooltip from "../actions/tooltip";
|
||||
import { watchedStatuses } from "../util/helpers";
|
||||
import { toUnderstandableStatus, watchedStatuses } from "../util/helpers";
|
||||
|
||||
export let status: WatchedStatus | undefined = undefined;
|
||||
export let handleStatusClick: (status: WatchedStatus | "DELETE") => void;
|
||||
@@ -10,12 +10,14 @@
|
||||
export let width = "40%";
|
||||
export let small = false;
|
||||
export let btnTooltip: string = "";
|
||||
export let disableInteraction: boolean = false;
|
||||
export let isForGame = false;
|
||||
|
||||
let statusesShown = false;
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="status"
|
||||
class={["status", disableInteraction ? "interaction-disabled" : ""].join(" ")}
|
||||
style={`width: ${width};`}
|
||||
on:click={(ev) => {
|
||||
ev.stopPropagation();
|
||||
@@ -38,7 +40,9 @@
|
||||
<button
|
||||
class="plain{status && status !== statusName ? ' not-active' : ''}"
|
||||
on:click={() => handleStatusClick(statusName)}
|
||||
use:tooltip={{ text: statusName }}
|
||||
use:tooltip={{
|
||||
text: toUnderstandableStatus(statusName, isForGame)
|
||||
}}
|
||||
>
|
||||
<Icon i={icon} />
|
||||
</button>
|
||||
@@ -63,6 +67,19 @@
|
||||
font-family: "Rampart One";
|
||||
height: 100%;
|
||||
|
||||
&.interaction-disabled {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
background-color: transparent;
|
||||
border: unset;
|
||||
fill: white;
|
||||
color: white;
|
||||
|
||||
span {
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
.no-icon {
|
||||
color: $text-color;
|
||||
font-size: 30px;
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
h3 {
|
||||
font-variant: small-caps;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -37,11 +33,6 @@
|
||||
}
|
||||
|
||||
div {
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
&.row {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
@@ -51,10 +42,6 @@
|
||||
& > div:first-of-type {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
&.btns button {
|
||||
width: min-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import tooltip from "../actions/tooltip";
|
||||
|
||||
export let name: string;
|
||||
export let value: string | number;
|
||||
export let large = false;
|
||||
export let href: string | undefined = undefined;
|
||||
export let disc: string | undefined = undefined;
|
||||
</script>
|
||||
|
||||
<a {href}>
|
||||
{#if disc}
|
||||
<div class="disclaimer" use:tooltip={{ text: disc, pos: "top" }}>*</div>
|
||||
{/if}
|
||||
<span class={large ? "large" : ""}>{value}</span>
|
||||
<span>{name}</span>
|
||||
</a>
|
||||
|
||||
<style lang="scss">
|
||||
a {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
flex-grow: 1;
|
||||
flex: 1 1 115px;
|
||||
padding: 20px 15px;
|
||||
background-color: $accent-color;
|
||||
border-radius: 8px;
|
||||
|
||||
> span:first-of-type {
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
|
||||
&.large {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
> span:last-child {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 8px;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 12px;
|
||||
margin-top: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { serverFeatures, userInfo, userSettings, watchedList } from "@/store";
|
||||
import { follows, serverFeatures, userInfo, userSettings, watchedList } from "@/store";
|
||||
import {
|
||||
UserType,
|
||||
type JellyfinFoundContent,
|
||||
@@ -8,19 +8,63 @@ import {
|
||||
type WatchedStatus,
|
||||
type WatchedUpdateRequest,
|
||||
type WatchedUpdateResponse,
|
||||
type UserSettings
|
||||
type UserSettings,
|
||||
type Follow,
|
||||
type PlayedAddRequest
|
||||
} 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 +79,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 +133,39 @@ 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 async function contentExistsOnJellyfin(
|
||||
type: MediaType,
|
||||
name: string,
|
||||
@@ -169,6 +216,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 +254,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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -137,7 +137,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
:global(button.plain) {
|
||||
:global(button.plain),
|
||||
:global(a.plain-btn) {
|
||||
background-color: transparent;
|
||||
color: $text-color;
|
||||
border: 0;
|
||||
@@ -155,6 +156,7 @@
|
||||
color: white;
|
||||
border: 1px solid white;
|
||||
border-radius: 6px;
|
||||
text-transform: capitalize;
|
||||
z-index: 99999;
|
||||
transition:
|
||||
top 100ms ease,
|
||||
@@ -262,3 +264,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,352 @@
|
||||
<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 requestModalShown = false;
|
||||
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 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;
|
||||
@@ -7,22 +7,29 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import DropFileButton from "@/lib/DropFileButton.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import { importedList } from "@/store";
|
||||
import { onMount } from "svelte";
|
||||
import papa from "papaparse";
|
||||
import type { ImportedList, MovaryHistory, MovaryRatings, MovaryWatchlist } from "@/types";
|
||||
import { json } from "@sveltejs/kit";
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
let dragEnterTarget: EventTarget | null;
|
||||
let isDragOver = false;
|
||||
let isLoading = false;
|
||||
|
||||
function processFiles(files?: FileList | null) {
|
||||
try {
|
||||
console.log("processFiles", files);
|
||||
if (!files) {
|
||||
if (!files || files?.length <= 0) {
|
||||
console.error("processFiles", "No files to process!");
|
||||
notify({
|
||||
type: "error",
|
||||
text: "File not found in dropped items. Please try again or refresh.",
|
||||
time: 6000
|
||||
});
|
||||
isDragOver = false;
|
||||
return;
|
||||
}
|
||||
isLoading = true;
|
||||
@@ -35,23 +42,25 @@
|
||||
}
|
||||
// Currently only support for importing one file at a time
|
||||
const file = files[0];
|
||||
if (file.type !== "text/plain") {
|
||||
if (file.type !== "text/plain" && file.type !== "text/csv") {
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Currently only text files are supported"
|
||||
text: "Currently only text and csv (TMDb export) files are supported"
|
||||
});
|
||||
isLoading = false;
|
||||
isDragOver = false;
|
||||
return;
|
||||
}
|
||||
const r = new FileReader();
|
||||
let type: "text-list" | "tmdb" = "text-list";
|
||||
if (file.type === "text/csv") type = "tmdb";
|
||||
r.addEventListener(
|
||||
"load",
|
||||
() => {
|
||||
if (r.result) {
|
||||
importedList.set({
|
||||
file,
|
||||
data: r.result.toString()
|
||||
data: r.result.toString(),
|
||||
type
|
||||
});
|
||||
goto("/import/process");
|
||||
}
|
||||
@@ -66,76 +75,199 @@
|
||||
}
|
||||
}
|
||||
|
||||
function importFile() {
|
||||
fileInput.click();
|
||||
async function readFile(fr: FileReader, file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const res = () => {
|
||||
fr.removeEventListener("load", res);
|
||||
fr.removeEventListener("error", rej);
|
||||
if (fr.result) {
|
||||
resolve(fr.result.toString());
|
||||
} else {
|
||||
reject("no result");
|
||||
}
|
||||
};
|
||||
const rej = () => {
|
||||
fr.removeEventListener("load", res);
|
||||
fr.removeEventListener("error", rej);
|
||||
reject();
|
||||
};
|
||||
fr.addEventListener("load", res);
|
||||
fr.addEventListener("error", rej);
|
||||
fr.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process movary import files.
|
||||
*
|
||||
* Movary exports 3 different files:
|
||||
*
|
||||
* - watchlist.csv = Planned movies.
|
||||
* - history.csv = Watched movies, movie can be watched multiple times.
|
||||
* - ratings.csv = Ratings for movies. One rating per movie.
|
||||
*
|
||||
* Export types explained better here:
|
||||
* https://github.com/sbondCo/Watcharr/issues/332#issuecomment-1920662244
|
||||
*/
|
||||
async function processFilesMovary(files?: FileList | null) {
|
||||
try {
|
||||
console.log("processFilesMovary", files);
|
||||
if (!files || files?.length <= 0) {
|
||||
console.error("processFilesMovary", "No files to process!");
|
||||
notify({
|
||||
type: "error",
|
||||
text: "File not found in dropped items. Please try again or refresh.",
|
||||
time: 6000
|
||||
});
|
||||
isDragOver = false;
|
||||
return;
|
||||
}
|
||||
if (files.length !== 3) {
|
||||
notify({
|
||||
type: "error",
|
||||
text: "You must select or drop 3 files: history.csv, ratings.csv and watchlist.csv.",
|
||||
time: 6000
|
||||
});
|
||||
isDragOver = false;
|
||||
return;
|
||||
}
|
||||
isLoading = true;
|
||||
// Read file data into strings
|
||||
let history: string | undefined;
|
||||
let ratings: string | undefined;
|
||||
let watchlist: string | undefined;
|
||||
const r = new FileReader();
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const f = files[i];
|
||||
if (f.name === "history.csv") {
|
||||
history = await readFile(r, f);
|
||||
} else if (f.name === "ratings.csv") {
|
||||
ratings = await readFile(r, f);
|
||||
} else if (f.name === "watchlist.csv") {
|
||||
watchlist = await readFile(r, f);
|
||||
}
|
||||
}
|
||||
if (!history || !ratings || !watchlist) {
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Failed to read history, ratings or watchlist. Ensure you have attached 3 files: history.csv, ratings.csv and watchlist.csv.",
|
||||
time: 6000
|
||||
});
|
||||
isDragOver = false;
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
console.log("loaded all files");
|
||||
// Convert csv strings into json
|
||||
const historyJson = papa.parse<MovaryHistory>(history.trim(), { header: true });
|
||||
const ratingsJson = papa.parse<MovaryRatings>(ratings.trim(), { header: true });
|
||||
const watchlistJson = papa.parse<MovaryWatchlist>(watchlist.trim(), { header: true });
|
||||
// Build toImport array
|
||||
const toImport: ImportedList[] = [];
|
||||
// Add all history movies (watched). There can be multiple entries for each movie.
|
||||
for (let i = 0; i < historyJson.data.length; i++) {
|
||||
const h = historyJson.data[i];
|
||||
// Skip if no tmdb id.
|
||||
if (!h.tmdbId) {
|
||||
continue;
|
||||
}
|
||||
// Skip if already added. The first time it is added we get all info needed from other entries.
|
||||
if (toImport.filter((ti) => ti.tmdbId == Number(h.tmdbId)).length > 0) {
|
||||
continue;
|
||||
}
|
||||
const ratingsEntry = ratingsJson.data.find((r) => r.tmdbId == h.tmdbId);
|
||||
const t: ImportedList = {
|
||||
name: h.title,
|
||||
tmdbId: Number(h.tmdbId),
|
||||
status: "FINISHED",
|
||||
type: "movie", // movary only supports movies
|
||||
datesWatched: [],
|
||||
thoughts: ""
|
||||
};
|
||||
// Movie can be watched more than once, get all entries to store all watch dates.
|
||||
const allEntries = historyJson.data.filter((he) => he.tmdbId === h.tmdbId);
|
||||
for (let i = 0; i < allEntries.length; i++) {
|
||||
const e = allEntries[i];
|
||||
if (e.watchedAt) {
|
||||
t.datesWatched?.push(new Date(e.watchedAt));
|
||||
}
|
||||
if (e.comment) {
|
||||
t.thoughts += e.comment + "\n";
|
||||
}
|
||||
}
|
||||
if (h.year) {
|
||||
t.year = h.year;
|
||||
}
|
||||
if (ratingsEntry && ratingsEntry?.userRating) {
|
||||
t.rating = Number(ratingsEntry.userRating);
|
||||
}
|
||||
toImport.push(t);
|
||||
}
|
||||
// Add all watchlist movies (planned).
|
||||
for (let i = 0; i < watchlistJson.data.length; i++) {
|
||||
const wl = watchlistJson.data[i];
|
||||
const existing = toImport.find((ti) => ti.tmdbId == Number(wl.tmdbId));
|
||||
// If already exists in toImport, simply update status to PLANNED.
|
||||
// The movie must have been completed in past, but added back to
|
||||
// the users movary watch list as they are planning to watch it again.
|
||||
if (existing) {
|
||||
existing.status = "PLANNED";
|
||||
continue;
|
||||
}
|
||||
toImport.push({
|
||||
name: wl.title,
|
||||
tmdbId: Number(wl.tmdbId),
|
||||
status: "PLANNED",
|
||||
type: "movie" // movary only supports movies
|
||||
});
|
||||
}
|
||||
console.log("toImport:", toImport);
|
||||
importedList.set({
|
||||
data: JSON.stringify(toImport),
|
||||
type: "movary"
|
||||
});
|
||||
goto("/import/process");
|
||||
} catch (err) {
|
||||
isLoading = false;
|
||||
notify({ type: "error", text: "Failed to read files!" });
|
||||
console.error("import: Failed to read files!", err);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!localStorage.getItem("token")) {
|
||||
goto("/login");
|
||||
}
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener("change", (ev) => {
|
||||
processFiles(fileInput.files);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="content">
|
||||
<div class="inner">
|
||||
<span class="header">
|
||||
<h2>Import Your Watchlist</h2>
|
||||
<h5 class="norm">beta</h5>
|
||||
</span>
|
||||
<div>
|
||||
<span class="header">
|
||||
<h2>Import Your Watchlist</h2>
|
||||
<h5 class="norm">beta</h5>
|
||||
</span>
|
||||
<!-- <h4 class="norm">Currently txt and csv (TMDb export) files are supported.</h4> -->
|
||||
</div>
|
||||
<div class="big-btns">
|
||||
{#if isLoading}
|
||||
<Spinner />
|
||||
{:else}
|
||||
<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();
|
||||
processFiles(ev.dataTransfer?.files);
|
||||
}}
|
||||
class={isDragOver ? "dragging-over" : ""}
|
||||
>
|
||||
<Icon i={isDragOver ? "add" : "document"} wh="100%" />
|
||||
<div>
|
||||
<h4 class="norm">
|
||||
{#if isDragOver}
|
||||
Import
|
||||
{:else}
|
||||
Browse
|
||||
{/if}
|
||||
</h4>
|
||||
<!-- <h5 class="norm">Or Drag And Drop</h5> -->
|
||||
</div>
|
||||
</button>
|
||||
<DropFileButton
|
||||
text=".txt list or .csv TMDb Export"
|
||||
filesSelected={(f) => processFiles(f)}
|
||||
/>
|
||||
|
||||
<DropFileButton
|
||||
icon="movary"
|
||||
text="Movary Exports"
|
||||
filesSelected={(f) => processFilesMovary(f)}
|
||||
allowSelectMultipleFiles
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" bind:this={fileInput} />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -153,6 +285,14 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.big-btns {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -161,61 +301,5 @@
|
||||
margin-top: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.big-btns {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-flow: row;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
|
||||
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>
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
import axios from "axios";
|
||||
import { onDestroy } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
import papa from "papaparse";
|
||||
|
||||
const wList = get(watchedList);
|
||||
|
||||
@@ -39,6 +40,7 @@
|
||||
let rList: ImportedList[] = [];
|
||||
let isImporting = false;
|
||||
let cancelled = false;
|
||||
let importText = "";
|
||||
|
||||
onDestroy(() => {
|
||||
cancelled = true;
|
||||
@@ -56,7 +58,8 @@
|
||||
return;
|
||||
}
|
||||
console.log("getList", list);
|
||||
if (list?.file.type === "text/plain") {
|
||||
if (list?.type === "text-list") {
|
||||
importText = "Text List";
|
||||
// Regex to match a year in between brackets,
|
||||
// which we assume is the release year of content.
|
||||
const yearRegex = new RegExp(/\([0-9]{4}\)/);
|
||||
@@ -73,6 +76,59 @@
|
||||
rList.push(l);
|
||||
}
|
||||
}
|
||||
} else if (list?.type === "tmdb") {
|
||||
importText = "TMDB";
|
||||
const s = papa.parse(list.data.trim(), { header: true });
|
||||
console.debug("parsed csv", s);
|
||||
for (let i = 0; i < s.data.length; i++) {
|
||||
try {
|
||||
const el = s.data[i] as any;
|
||||
if (el) {
|
||||
// Skip if no name or tmdb id
|
||||
if (!el.Name && !el["TMDb ID"]) {
|
||||
console.warn("Skipping item with no name or tmdb id", el);
|
||||
return;
|
||||
}
|
||||
const l: ImportedList = { name: el.Name };
|
||||
const year = el["Release Date"] ? new Date(el["Release Date"]) : undefined;
|
||||
if (year) {
|
||||
l.year = String(year.getFullYear());
|
||||
}
|
||||
if (el.Type === "movie" || el.Type === "tv") {
|
||||
l.type = el.Type;
|
||||
}
|
||||
if (el["TMDb ID"]) {
|
||||
l.tmdbId = Number(el["TMDb ID"]);
|
||||
}
|
||||
if (el["Your Rating"]) {
|
||||
l.rating = Math.floor(Number(el["Your Rating"]));
|
||||
}
|
||||
if (el["Date Rated"]) {
|
||||
l.ratingCustomDate = new Date(el["Date Rated"]);
|
||||
}
|
||||
rList.push(l);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to process an item!", err);
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Failed to process an item!"
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (list?.type === "movary") {
|
||||
importText = "Movary";
|
||||
try {
|
||||
const s = JSON.parse(list.data);
|
||||
// Builds imported list in previous step for ease.
|
||||
rList = s;
|
||||
} catch (err) {
|
||||
console.error("Movary import processing failed!", err);
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Processing failed!. Please report this issue if it persists."
|
||||
});
|
||||
}
|
||||
}
|
||||
// TODO: remove duplicate names in list
|
||||
return list;
|
||||
@@ -111,10 +167,15 @@
|
||||
try {
|
||||
console.log("Importing", li);
|
||||
await doImport(li);
|
||||
await sleep(2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to import item:", li, "reason:", err);
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Failed to import an item! Check console for more info.",
|
||||
time: Infinity
|
||||
});
|
||||
}
|
||||
await sleep(1500);
|
||||
}
|
||||
importedList.set(undefined);
|
||||
if (
|
||||
@@ -177,9 +238,9 @@
|
||||
const w = resp.data.watchedEntry;
|
||||
if (w) {
|
||||
const release =
|
||||
w.content.type === "movie" ? w.content.release_date : w.content.first_air_date;
|
||||
w.content?.type === "movie" ? w.content?.release_date : w.content?.first_air_date;
|
||||
if (release) item.year = String(new Date(Date.parse(release)).getFullYear());
|
||||
item.type = w.content.type;
|
||||
item.type = w.content?.type;
|
||||
wList.push(w);
|
||||
watchedList.update(() => wList);
|
||||
}
|
||||
@@ -200,7 +261,7 @@
|
||||
<div class="content">
|
||||
<div class="inner">
|
||||
{#if rList}
|
||||
<h2>Importing {list?.file.name ? list.file.name : ""}</h2>
|
||||
<h2>Importing {importText ? `From ${importText}` : ""}</h2>
|
||||
<h5 class="norm">
|
||||
{#if !isImporting}
|
||||
Review your imported list and fix any problems.
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import WatchedList from "@/lib/WatchedList.svelte";
|
||||
import tooltip from "@/lib/actions/tooltip.js";
|
||||
import UserAvatar from "@/lib/img/UserAvatar.svelte";
|
||||
import { followUser, unfollowUser } from "@/lib/util/api.js";
|
||||
import { notify } from "@/lib/util/notify.js";
|
||||
import type { Watched } from "@/types.js";
|
||||
import { follows } from "@/store.js";
|
||||
import type { PublicUser, Watched } from "@/types.js";
|
||||
import axios from "axios";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
|
||||
export let data;
|
||||
|
||||
let followBtnDisabled = false;
|
||||
let user: PublicUser | undefined;
|
||||
|
||||
$: isFollowing = !!$follows?.find((f) => f.followedUser.id === Number(data.id));
|
||||
|
||||
async function getPublicWatchedList(id?: number, username?: string) {
|
||||
if (!id || !username) {
|
||||
console.error("getPublicWatchedList requires and id and username", id, username);
|
||||
@@ -15,13 +26,63 @@
|
||||
}
|
||||
return (await axios.get(`/watched/${id}/${username}`)).data as Watched[];
|
||||
}
|
||||
|
||||
async function getPublicUser() {
|
||||
return (await axios.get(`/user/public/${data.id}/${data.username}`)).data as PublicUser;
|
||||
}
|
||||
|
||||
async function follow() {
|
||||
followBtnDisabled = true;
|
||||
console.log(isFollowing);
|
||||
if (isFollowing) {
|
||||
await unfollowUser(Number(data.id));
|
||||
} else {
|
||||
await followUser(Number(data.id));
|
||||
}
|
||||
followBtnDisabled = false;
|
||||
}
|
||||
|
||||
$: {
|
||||
user = undefined;
|
||||
if (data?.id && data?.username) {
|
||||
getPublicUser()
|
||||
.then((u) => {
|
||||
user = u;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("getPublicUser failed!", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.username}'s Watched List</title>
|
||||
</svelte:head>
|
||||
|
||||
<h2 class="norm">{data.username}'s Watched List</h2>
|
||||
<div class="content">
|
||||
<div class="inner">
|
||||
<UserAvatar img={user?.avatar} />
|
||||
<div class="basic-ctr">
|
||||
<div class="name-row">
|
||||
<h2 title={user?.username}>
|
||||
{data.username}
|
||||
</h2>
|
||||
<button
|
||||
class="plain"
|
||||
disabled={followBtnDisabled}
|
||||
on:click={follow}
|
||||
use:tooltip={{ text: isFollowing ? "Unfollow" : "Follow" }}
|
||||
>
|
||||
<Icon i={isFollowing ? "person-minus" : "person-add"} />
|
||||
</button>
|
||||
</div>
|
||||
{#if user?.bio}
|
||||
<span title={user?.bio}>{user?.bio}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#await getPublicWatchedList(Number(data.id), data.username)}
|
||||
<Spinner />
|
||||
@@ -32,9 +93,62 @@
|
||||
{/await}
|
||||
|
||||
<style lang="scss">
|
||||
h2 {
|
||||
.content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin: 20px 30px;
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 20px 30px;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
textarea {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
resize: none;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.basic-ctr {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
|
||||
.name-row {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 15px;
|
||||
|
||||
h2 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: auto;
|
||||
fill: $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-family: monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import { page } from "$app/stores";
|
||||
import RequestMovie from "@/lib/request/RequestMovie.svelte";
|
||||
import Error from "@/lib/Error.svelte";
|
||||
import FollowedThoughts from "@/lib/content/FollowedThoughts.svelte";
|
||||
|
||||
export let data;
|
||||
|
||||
@@ -33,7 +34,9 @@
|
||||
let trailerShown = false;
|
||||
let jellyfinUrl: string | undefined;
|
||||
|
||||
$: wListItem = $watchedList.find((w) => w.content.tmdbId === data.movieId);
|
||||
$: wListItem = $watchedList.find(
|
||||
(w) => w.content?.type === "movie" && w.content?.tmdbId === data.movieId
|
||||
);
|
||||
|
||||
let movieId: number | undefined;
|
||||
let movie: TMDBMovieDetails | undefined;
|
||||
@@ -118,7 +121,7 @@
|
||||
<Title
|
||||
title={movie.title}
|
||||
homepage={movie.homepage}
|
||||
releaseDate={movie.release_date}
|
||||
releaseYear={new Date(Date.parse(movie.release_date)).getFullYear()}
|
||||
voteAverage={movie.vote_average}
|
||||
voteCount={movie.vote_count}
|
||||
/>
|
||||
@@ -178,13 +181,20 @@
|
||||
placeholder={`My thoughts on ${movie.title}`}
|
||||
value={wListItem?.thoughts}
|
||||
on:blur={(e) => {
|
||||
console.log(e.currentTarget?.value);
|
||||
if (wListItem?.thoughts === e.currentTarget.value) {
|
||||
// thoughts didn't change
|
||||
return;
|
||||
}
|
||||
contentChanged(undefined, undefined, e.currentTarget?.value);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if movieId}
|
||||
<FollowedThoughts mediaType="movie" mediaId={movieId} />
|
||||
{/if}
|
||||
|
||||
{#await getMovieCredits()}
|
||||
<Spinner />
|
||||
{:then credits}
|
||||
|
||||
@@ -4,18 +4,28 @@
|
||||
import Error from "@/lib/Error.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import Setting from "@/lib/settings/Setting.svelte";
|
||||
import Stat from "@/lib/stats/Stat.svelte";
|
||||
import Stats from "@/lib/stats/Stats.svelte";
|
||||
import { updateUserSetting } from "@/lib/util/api";
|
||||
import { getOrdinalSuffix, monthsShort, toggleTheme } from "@/lib/util/helpers";
|
||||
import { appTheme, userInfo, userSettings } from "@/store";
|
||||
import type { Profile } from "@/types";
|
||||
import type { Image, Profile } from "@/types";
|
||||
import axios from "axios";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import UserAvatar from "@/lib/img/UserAvatar.svelte";
|
||||
import PwChangeModal from "@/routes/(app)/profile/modals/PwChangeModal.svelte";
|
||||
|
||||
$: user = $userInfo;
|
||||
$: settings = $userSettings;
|
||||
$: selectedTheme = $appTheme;
|
||||
|
||||
let privateDisabled = false;
|
||||
let privateThoughtsDisabled = false;
|
||||
let exportDisabled = false;
|
||||
let hideSpoilersDisabled = false;
|
||||
let includePreviouslyWatchedDisabled = false;
|
||||
let pwChangeModalOpen = false;
|
||||
let getProfilePromise = getProfile();
|
||||
|
||||
async function getProfile() {
|
||||
return (await axios.get(`/profile`)).data as Profile;
|
||||
@@ -26,32 +36,137 @@
|
||||
monthsShort[d.getMonth()]
|
||||
} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
function updateBio(ev: FocusEvent & { currentTarget: EventTarget & HTMLTextAreaElement }) {
|
||||
const newBio = ev?.currentTarget?.value;
|
||||
if (typeof newBio !== "string") {
|
||||
console.warn("updateBio called without any value", newBio);
|
||||
return;
|
||||
}
|
||||
const nid = notify({ text: "Updating Bio", type: "loading" });
|
||||
axios
|
||||
.post("/user/bio", { newBio: newBio })
|
||||
.then(() => {
|
||||
if (user) {
|
||||
user.bio = newBio;
|
||||
notify({ id: nid, text: "Updated Bio", type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
notify({
|
||||
id: nid,
|
||||
text: err?.response?.data?.error ?? "Failed to update bio",
|
||||
type: "error"
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function avatarDropped(ev: Event) {
|
||||
const files = (ev.currentTarget as HTMLInputElement)?.files;
|
||||
if (!files || files?.length <= 0) {
|
||||
console.error("avatarDropped: no file found");
|
||||
return;
|
||||
}
|
||||
const nid = notify({ text: "Uploading avatar", type: "loading" });
|
||||
axios
|
||||
.postForm(
|
||||
"/user/avatar",
|
||||
{ avatar: files[0] },
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data"
|
||||
}
|
||||
}
|
||||
)
|
||||
.then((r) => {
|
||||
if (user) {
|
||||
user.avatar = r.data as Image;
|
||||
notify({ id: nid, text: "Avatar Uploaded", type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("uploading avatar failed", err);
|
||||
notify({
|
||||
id: nid,
|
||||
text: err?.response?.data?.error ?? "Failed to upload avatar",
|
||||
type: "error"
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadWatchedList() {
|
||||
const nid = notify({ text: "Exporting", type: "loading" });
|
||||
try {
|
||||
exportDisabled = true;
|
||||
// We re-fetch, to ensure data we export is up to date.
|
||||
const r = await axios.get("/watched");
|
||||
console.log(r.data);
|
||||
if (!r.data || r.data?.length <= 0) {
|
||||
notify({
|
||||
id: nid,
|
||||
text: "Can't export an empty watch list!",
|
||||
type: "error",
|
||||
time: 10000
|
||||
});
|
||||
exportDisabled = false;
|
||||
return;
|
||||
}
|
||||
const file = new Blob([JSON.stringify(r.data, undefined, 2)], {
|
||||
type: "application/json"
|
||||
});
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(file);
|
||||
a.download = "watcharr-export.json";
|
||||
a.click();
|
||||
exportDisabled = false;
|
||||
notify({ id: nid, text: "Successfully Exported", type: "success" });
|
||||
} catch (err) {
|
||||
console.error("downloadWatchedList failed!", err);
|
||||
notify({ id: nid, text: "Export Failed!", type: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes in number of minutes and converts to readable.
|
||||
* eg into hours and minutes.
|
||||
*/
|
||||
function toFormattedMinutes(m: number) {
|
||||
const hours = Math.floor(m / 60);
|
||||
const minutes = m % 60;
|
||||
return `${hours ? `${hours}h ` : ""}${minutes}m`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="content">
|
||||
<div class="inner">
|
||||
<h2 title={user?.username}>Hey {user?.username}</h2>
|
||||
<div class="user-basic-info">
|
||||
<UserAvatar img={user?.avatar} {avatarDropped} />
|
||||
<div>
|
||||
<h2 title={user?.username}>
|
||||
<span style="font-weight: normal; font-variant: all-small-caps;">Hey</span>
|
||||
{user?.username}
|
||||
</h2>
|
||||
<textarea rows="1" placeholder="my bio" on:blur={updateBio} value={user?.bio} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
{#await getProfile()}
|
||||
<Stats>
|
||||
{#await getProfilePromise}
|
||||
<Spinner />
|
||||
{:then profile}
|
||||
<div>
|
||||
<span>{formatDate(new Date(profile.joined))}</span>
|
||||
<span>Joined</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="large">{profile.moviesWatched}</span>
|
||||
<span>Movies Watched</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="large">{profile.showsWatched}</span>
|
||||
<span>Shows Watched</span>
|
||||
</div>
|
||||
<Stat name="Joined" value={formatDate(new Date(profile.joined))} />
|
||||
<Stat name="Movies Watched" value={profile.moviesWatched} large />
|
||||
<Stat name="Shows Watched" value={profile.showsWatched} large />
|
||||
<Stat name="Watching Movies" value={toFormattedMinutes(profile.moviesWatchedRuntime)} />
|
||||
<Stat
|
||||
name="Watching Shows"
|
||||
value={toFormattedMinutes(profile.showsWatchedRuntime)}
|
||||
disc="This is very inaccurate 🚀"
|
||||
/>
|
||||
{:catch err}
|
||||
<Error error={err} pretty="Failed to get stats!" />
|
||||
{/await}
|
||||
</div>
|
||||
</Stats>
|
||||
|
||||
<div class="settings">
|
||||
<h3 class="norm">Settings</h3>
|
||||
@@ -90,6 +205,26 @@
|
||||
/>
|
||||
</Setting>
|
||||
|
||||
{#if !settings?.private}
|
||||
<Setting
|
||||
title="Private Thoughts"
|
||||
desc="Hide your watched list thoughts from followers?"
|
||||
row
|
||||
>
|
||||
<Checkbox
|
||||
name="privateThoughts"
|
||||
disabled={privateDisabled}
|
||||
value={settings?.privateThoughts}
|
||||
toggled={(on) => {
|
||||
privateThoughtsDisabled = true;
|
||||
updateUserSetting("privateThoughts", on, () => {
|
||||
privateThoughtsDisabled = false;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Setting>
|
||||
{/if}
|
||||
|
||||
<Setting title="Hide Spoilers" desc="Do you want to hide episode info?" row>
|
||||
<Checkbox
|
||||
name="hideSpoilers"
|
||||
@@ -104,9 +239,42 @@
|
||||
/>
|
||||
</Setting>
|
||||
|
||||
<Setting
|
||||
title="Include Previously Watched"
|
||||
desc="Do you want to include previously watched content in the 'finished' filter, even if their status has now been changed?"
|
||||
row
|
||||
>
|
||||
<Checkbox
|
||||
name="includePreviouslyWatched"
|
||||
disabled={includePreviouslyWatchedDisabled}
|
||||
value={settings?.includePreviouslyWatched}
|
||||
toggled={(on) => {
|
||||
includePreviouslyWatchedDisabled = true;
|
||||
updateUserSetting("includePreviouslyWatched", on, () => {
|
||||
includePreviouslyWatchedDisabled = false;
|
||||
// Get profile stats again
|
||||
getProfilePromise = getProfile();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Setting>
|
||||
<div class="row btns">
|
||||
<button on:click={() => goto("/import")}>Import</button>
|
||||
<button on:click={() => downloadWatchedList()} disabled={exportDisabled}>Export</button>
|
||||
<button
|
||||
on:click={() => {
|
||||
pwChangeModalOpen = true;
|
||||
}}>Change Password</button
|
||||
>
|
||||
</div>
|
||||
{#if pwChangeModalOpen}
|
||||
<PwChangeModal
|
||||
userName={user?.username}
|
||||
onClose={() => {
|
||||
pwChangeModalOpen = false;
|
||||
}}
|
||||
></PwChangeModal>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,7 +284,7 @@
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 0 30px 0 30px;
|
||||
padding: 0 30px 30px 30px;
|
||||
|
||||
.inner {
|
||||
min-width: 400px;
|
||||
@@ -140,36 +308,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
.user-basic-info {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 12px;
|
||||
margin-top: 15px;
|
||||
gap: 20px;
|
||||
|
||||
@media screen and (max-width: 440px) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
> div {
|
||||
& > div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
flex-grow: 1;
|
||||
padding: 20px 15px;
|
||||
background-color: $accent-color;
|
||||
border-radius: 8px;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
> span:first-child {
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
textarea {
|
||||
resize: none;
|
||||
|
||||
&.large {
|
||||
font-size: 32px;
|
||||
&:not(:focus) {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
> span:last-child {
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +353,7 @@
|
||||
align-items: center;
|
||||
|
||||
&.btns button {
|
||||
width: min-content;
|
||||
width: max-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
import Modal from "@/lib/Modal.svelte";
|
||||
import type { ChangePasswordForm } from "@/types";
|
||||
import { changeUserPassword } from "$lib/util/api";
|
||||
|
||||
export let userName: string | undefined;
|
||||
export let onClose: () => void;
|
||||
export let changepswd: ChangePasswordForm = {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
reEnteredNewPassword: ""
|
||||
};
|
||||
|
||||
let error: string;
|
||||
let errs: string[] = [];
|
||||
|
||||
function checkForm() {
|
||||
errs = [];
|
||||
console.log("Change Password Form - Status: Checking if any form inputs are missing");
|
||||
if (!changepswd.currentPassword) {
|
||||
errs.push("Current Password");
|
||||
}
|
||||
if (!changepswd.newPassword) {
|
||||
errs.push("New Password");
|
||||
}
|
||||
if (!changepswd.reEnteredNewPassword) {
|
||||
errs.push("Re-Entered New Password");
|
||||
}
|
||||
if (errs.length > 0) {
|
||||
error = `Missing required params: ${errs.join(", ")}`;
|
||||
console.log(
|
||||
`Change Password Form - Error: following form inputs are missing:\n${errs.join("\n")}`
|
||||
);
|
||||
} else {
|
||||
console.log("Change Password Form - Status: All form inputs are present");
|
||||
checkFormPasswordsMatch();
|
||||
}
|
||||
}
|
||||
|
||||
function checkFormPasswordsMatch() {
|
||||
console.log(
|
||||
"Change Password Form - Status: Checking if new password and re-entered new password match"
|
||||
);
|
||||
if (changepswd.newPassword !== changepswd.reEnteredNewPassword) {
|
||||
error = "New password and re-entered new password do not match";
|
||||
console.log(
|
||||
"Change Password Form - Error: New password and re-entered new password do not match"
|
||||
);
|
||||
} else {
|
||||
console.log("Change Password Form - Status: New password and re-entered new password match");
|
||||
error = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(ev: SubmitEvent) {
|
||||
checkForm();
|
||||
if (!error) {
|
||||
console.log(
|
||||
"Change Password Form - Status: Form inputs on the frontend are valid - sending request to change password to the server"
|
||||
);
|
||||
const fd = new FormData(ev.target! as HTMLFormElement);
|
||||
changepswd.currentPassword = fd.get("current-password") as string;
|
||||
changepswd.newPassword = fd.get("new-password") as string;
|
||||
changeUserPassword(changepswd.currentPassword, changepswd.newPassword, (errMsg) => {
|
||||
if (errMsg) {
|
||||
error = errMsg;
|
||||
return;
|
||||
}
|
||||
console.log("Change Password Form - Status: Password changed successfully");
|
||||
onClose();
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
title="Change Password"
|
||||
desc="Use the below form to change your password for account {userName}"
|
||||
{onClose}
|
||||
>
|
||||
{#if error}
|
||||
<span class="error">{error}!</span>
|
||||
{/if}
|
||||
<form on:submit|preventDefault={handleSubmit}>
|
||||
<div class="form-input-container">
|
||||
<div class="form-input">
|
||||
<!--Hiding username info as it is still useful to password managers-->
|
||||
<!--https://www.chromium.org/developers/design-documents/create-amazing-password-forms/#use-hidden-fields-for-implicit-information-->
|
||||
<label for="username" id="username-label">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
id="username-input"
|
||||
value={userName}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-input">
|
||||
<label for="current-password">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="current-password"
|
||||
placeholder="Current password"
|
||||
autocomplete="current-password"
|
||||
bind:value={changepswd.currentPassword}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-input">
|
||||
<label for="new-password">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="new-password"
|
||||
placeholder="New password"
|
||||
autocomplete="new-password"
|
||||
bind:value={changepswd.newPassword}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-input">
|
||||
<label for="re-entered-new-password">Re-enter New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="re-entered-new-password"
|
||||
placeholder="Re-enter new password"
|
||||
autocomplete="new-password"
|
||||
bind:value={changepswd.reEnteredNewPassword}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit">Change Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
#username-label,
|
||||
#username-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: rgb(221, 48, 48);
|
||||
text-transform: capitalize;
|
||||
color: white;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-input-container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 20px;
|
||||
margin: 0 15px;
|
||||
}
|
||||
|
||||
.form-input-container > button {
|
||||
margin-left: auto;
|
||||
width: max-content;
|
||||
}
|
||||
</style>
|
||||
@@ -1,23 +1,92 @@
|
||||
<script lang="ts">
|
||||
import Poster from "@/lib/poster/Poster.svelte";
|
||||
import PosterList from "@/lib/poster/PosterList.svelte";
|
||||
import { searchQuery, watchedList } from "@/store";
|
||||
import { searchQuery, serverFeatures, watchedList } from "@/store";
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import axios from "axios";
|
||||
import { getWatchedDependedProps } from "@/lib/util/helpers";
|
||||
import { getWatchedDependedProps, getPlayedDependedProps } from "@/lib/util/helpers";
|
||||
import PersonPoster from "@/lib/poster/PersonPoster.svelte";
|
||||
import type { ContentSearch, PublicUser } from "@/types";
|
||||
import type {
|
||||
ContentSearch,
|
||||
ContentSearchMovie,
|
||||
ContentSearchPerson,
|
||||
ContentSearchTv,
|
||||
GameSearch,
|
||||
MediaType,
|
||||
PublicUser
|
||||
} from "@/types";
|
||||
import UsersList from "@/lib/UsersList.svelte";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import Error from "@/lib/Error.svelte";
|
||||
import GamePoster from "@/lib/poster/GamePoster.svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { notify } from "@/lib/util/notify.js";
|
||||
|
||||
export let data;
|
||||
|
||||
$: searchQ = $searchQuery;
|
||||
$: wList = $watchedList;
|
||||
|
||||
type GameWithMediaType = GameSearch & { media_type: "game" };
|
||||
type CombinedResult =
|
||||
| ContentSearchMovie
|
||||
| ContentSearchTv
|
||||
| ContentSearchPerson
|
||||
| GameWithMediaType;
|
||||
|
||||
async function search(query: string) {
|
||||
return (await axios.get(`/content/${query}`)).data as ContentSearch;
|
||||
const f = get(serverFeatures);
|
||||
if (!f.games) {
|
||||
console.log("Search: Only for movies/tv");
|
||||
return (await axios.get<ContentSearch>(`/content/${query}`)).data.results;
|
||||
}
|
||||
console.log("Search: For movies/tv and games");
|
||||
// To get around promise.all rejecting both promises when one fails,
|
||||
// catch them separately and return empty object so we can still
|
||||
// display the other media types.
|
||||
const r = await Promise.all([
|
||||
axios.get<ContentSearch>(`/content/${query}`).catch((err) => {
|
||||
console.error("Movies/Tv search failed!", err);
|
||||
notify({ text: "Movie/Tv Search Failed!", type: "error" });
|
||||
return { data: { results: [] } };
|
||||
}),
|
||||
axios.get<GameSearch[]>(`/game/search/${query}`).catch((err) => {
|
||||
console.error("Game search failed!", err);
|
||||
notify({ text: "Game Search Failed!", type: "error" });
|
||||
return { data: [] };
|
||||
})
|
||||
]);
|
||||
const games: GameWithMediaType[] = r[1].data.map((g) => ({
|
||||
...g,
|
||||
media_type: "game"
|
||||
}));
|
||||
const d = new Array<CombinedResult>().concat
|
||||
.apply([], [r[0].data.results, games])
|
||||
?.sort((a, b) => {
|
||||
let name = "";
|
||||
if (a.media_type === "game" || a.media_type === "tv" || a.media_type === "person") {
|
||||
name = a.name ?? "";
|
||||
} else if (a.media_type === "movie") {
|
||||
name = a.title ?? "";
|
||||
}
|
||||
|
||||
let name2 = "";
|
||||
if (b.media_type === "game" || b.media_type === "tv" || b.media_type === "person") {
|
||||
name2 = b.name ?? "";
|
||||
} else if (b.media_type === "movie") {
|
||||
name2 = b.title ?? "";
|
||||
}
|
||||
|
||||
if (name < name2) {
|
||||
return 1;
|
||||
}
|
||||
if (name > name2) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
return d;
|
||||
}
|
||||
|
||||
async function searchUsers(query: string) {
|
||||
@@ -39,34 +108,105 @@
|
||||
<title>Content Search</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if data.slug}
|
||||
{#await searchUsers(data.slug) then results}
|
||||
{#if results?.length > 0}
|
||||
<UsersList users={results} />
|
||||
{/if}
|
||||
{:catch err}
|
||||
<PageError pretty="Failed to load watched list!" error={err} />
|
||||
{/await}
|
||||
<div class="content">
|
||||
<div class="inner">
|
||||
{#if data.slug}
|
||||
{#await searchUsers(data.slug) then results}
|
||||
{#if results?.length > 0}
|
||||
<UsersList users={results} />
|
||||
{/if}
|
||||
{:catch err}
|
||||
<PageError pretty="Failed to load users!" error={err} />
|
||||
{/await}
|
||||
|
||||
{#await search(data.slug)}
|
||||
<Spinner />
|
||||
{:then results}
|
||||
<PosterList>
|
||||
{#if results?.results?.length > 0}
|
||||
{#each results.results as w (w.id)}
|
||||
{#if w.media_type === "person"}
|
||||
<PersonPoster id={w.id} name={w.name} path={w.profile_path} />
|
||||
{#await search(data.slug)}
|
||||
<Spinner />
|
||||
{:then results}
|
||||
<h2>Results</h2>
|
||||
<PosterList>
|
||||
{#if results?.length > 0}
|
||||
{#each results as w (w.id)}
|
||||
{#if w.media_type === "person"}
|
||||
<PersonPoster id={w.id} name={w.name} path={w.profile_path} />
|
||||
{:else if w.media_type === "game"}
|
||||
<GamePoster
|
||||
media={{
|
||||
id: w.id,
|
||||
coverId: w.cover.image_id,
|
||||
name: w.name,
|
||||
summary: w.summary,
|
||||
firstReleaseDate: w.first_release_date
|
||||
}}
|
||||
{...getPlayedDependedProps(w.id, wList)}
|
||||
/>
|
||||
{:else}
|
||||
<Poster media={w} {...getWatchedDependedProps(w.id, w.media_type, wList)} />
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<Poster media={w} {...getWatchedDependedProps(w.id, w.media_type, wList)} />
|
||||
No Search Results!
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
No Search Results!
|
||||
{/if}
|
||||
</PosterList>
|
||||
{:catch err}
|
||||
<PageError pretty="Failed to load watched list!" error={err} />
|
||||
{/await}
|
||||
{:else}
|
||||
<h2>No Search Query!</h2>
|
||||
{/if}
|
||||
</PosterList>
|
||||
{:catch err}
|
||||
<Error pretty="Failed to load results!" error={err} />
|
||||
{/await}
|
||||
|
||||
<!-- {#await searchGames(data.slug)}
|
||||
<Spinner />
|
||||
{:then results}
|
||||
<h2>Results</h2>
|
||||
<PosterList>
|
||||
{#if results?.length > 0}
|
||||
{#each results as w (w.id)}
|
||||
<GamePoster media={w} />
|
||||
{/each}
|
||||
{:else}
|
||||
No Search Results!
|
||||
{/if}
|
||||
</PosterList>
|
||||
{:catch err}
|
||||
<Error pretty="Failed to load results!" error={err} />
|
||||
{/await} -->
|
||||
|
||||
<!-- {#await search(data.slug)}
|
||||
<Spinner />
|
||||
{:then results}
|
||||
<h2>Results</h2>
|
||||
<PosterList>
|
||||
{#if results?.results?.length > 0}
|
||||
{#each results.results as w (w.id)}
|
||||
{#if w.media_type === "person"}
|
||||
<PersonPoster id={w.id} name={w.name} path={w.profile_path} />
|
||||
{:else}
|
||||
<Poster media={w} {...getWatchedDependedProps(w.id, w.media_type, wList)} />
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
No Search Results!
|
||||
{/if}
|
||||
</PosterList>
|
||||
{:catch err}
|
||||
<Error pretty="Failed to load results!" error={err} />
|
||||
{/await} -->
|
||||
{:else}
|
||||
<h2>No Search Query!</h2>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
|
||||
.inner {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
|
||||
h2 {
|
||||
margin-left: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import type { RadarrSettings, ServerConfig, SonarrSettings } from "@/types";
|
||||
import type { Content, RadarrSettings, ServerConfig, SonarrSettings } from "@/types";
|
||||
import axios from "axios";
|
||||
import SonarrModal from "./modals/SonarrModal.svelte";
|
||||
import SettingsList from "@/lib/settings/SettingsList.svelte";
|
||||
@@ -11,6 +11,10 @@
|
||||
import SettingButton from "@/lib/settings/SettingButton.svelte";
|
||||
import RadarrModal from "./modals/RadarrModal.svelte";
|
||||
import { getServerFeatures } from "@/lib/util/api";
|
||||
import Stats from "@/lib/stats/Stats.svelte";
|
||||
import Error from "@/lib/Error.svelte";
|
||||
import Stat from "@/lib/stats/Stat.svelte";
|
||||
import TwitchModal from "./modals/TwitchModal.svelte";
|
||||
|
||||
let serverConfig: ServerConfig;
|
||||
let sonarrModalOpen = false;
|
||||
@@ -19,6 +23,7 @@
|
||||
let radarrModalOpen = false;
|
||||
let radarrServerEditing: RadarrSettings;
|
||||
let radarrModalEditing = false;
|
||||
let twitchModalOpen = false;
|
||||
// Disabled vars for disabling inputs until api request completes
|
||||
let signupDisabled = false;
|
||||
let debugDisabled = false;
|
||||
@@ -55,12 +60,57 @@
|
||||
if (typeof done !== "undefined") done();
|
||||
});
|
||||
}
|
||||
|
||||
interface ServerStats {
|
||||
users: number;
|
||||
privateUsers: number;
|
||||
watchedMovies: number;
|
||||
watchedShows: number;
|
||||
watchedSeasons: number;
|
||||
mostWatchedMovie: Content;
|
||||
mostWatchedShow: Content;
|
||||
activities: number;
|
||||
}
|
||||
|
||||
async function getServerStats() {
|
||||
return (await axios.get("/server/stats")).data as ServerStats;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="content">
|
||||
<div class="inner">
|
||||
<SettingsList>
|
||||
<h2>Server Settings</h2>
|
||||
|
||||
<Stats>
|
||||
{#await getServerStats()}
|
||||
<Spinner />
|
||||
{:then stats}
|
||||
<Stat name="Users" value={stats.users} large />
|
||||
<Stat name="Private Users" value={stats.privateUsers} large />
|
||||
<Stat name="Watched Movies" value={stats.watchedMovies} large />
|
||||
<Stat name="Watched Shows" value={stats.watchedShows} large />
|
||||
<Stat name="Watched Seasons" value={stats.watchedSeasons} large />
|
||||
<Stat name="Activities" value={stats.activities} large />
|
||||
{#if stats.mostWatchedMovie?.title}
|
||||
<Stat
|
||||
name="Most Watched Movie"
|
||||
value={stats.mostWatchedMovie.title}
|
||||
href="/movie/{stats.mostWatchedMovie.tmdbId}"
|
||||
/>
|
||||
{/if}
|
||||
{#if stats.mostWatchedShow?.title}
|
||||
<Stat
|
||||
name="Most Watched Show"
|
||||
value={stats.mostWatchedShow.title}
|
||||
href="/tv/{stats.mostWatchedShow.tmdbId}"
|
||||
/>
|
||||
{/if}
|
||||
{:catch err}
|
||||
<Error error={err} pretty="Failed to get server stats!" />
|
||||
{/await}
|
||||
</Stats>
|
||||
|
||||
{#await getServerConfig()}
|
||||
<Spinner />
|
||||
{:then}
|
||||
@@ -138,6 +188,17 @@
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<Setting title="Twitch">
|
||||
<SettingButton
|
||||
title="Twitch"
|
||||
desc="Twitch application credentials for enabling game support (via IGDB)."
|
||||
icon={Object.keys(serverConfig.TWITCH).length > 0 ? "arrow" : "add"}
|
||||
onClick={() => {
|
||||
twitchModalOpen = true;
|
||||
}}
|
||||
/>
|
||||
</Setting>
|
||||
|
||||
<Setting title="Sonarr">
|
||||
{#if serverConfig.SONARR?.length > 0}
|
||||
{#each serverConfig.SONARR as server}
|
||||
@@ -200,6 +261,19 @@
|
||||
/>
|
||||
</Setting>
|
||||
|
||||
{#if twitchModalOpen}
|
||||
<TwitchModal
|
||||
cfg={serverConfig.TWITCH}
|
||||
onClose={() => {
|
||||
// "temporary" solution to showing added servers
|
||||
// and reloading data to revert modified but not saved changes.
|
||||
getServerConfig();
|
||||
getServerFeatures();
|
||||
twitchModalOpen = false;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if sonarrModalOpen}
|
||||
<SonarrModal
|
||||
servarr={sonarrServerEditing}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import Modal from "@/lib/Modal.svelte";
|
||||
import Setting from "@/lib/settings/Setting.svelte";
|
||||
import SettingsList from "@/lib/settings/SettingsList.svelte";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import type { TwitchSettings } from "@/types";
|
||||
import axios from "axios";
|
||||
|
||||
export let cfg: TwitchSettings;
|
||||
export let onClose: () => void;
|
||||
|
||||
let error: string;
|
||||
let formDisabled = false;
|
||||
|
||||
function checkForm() {
|
||||
let errs: string[] = [];
|
||||
if (!cfg.clientId) {
|
||||
errs.push("Client ID");
|
||||
}
|
||||
if (!cfg.clientSecret) {
|
||||
errs.push("Client Secret");
|
||||
}
|
||||
if (errs.length > 0) {
|
||||
error = `Missing required params: ${errs.join(", ")}`;
|
||||
} else {
|
||||
error = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
checkForm();
|
||||
// if no error set from checkForm func, continue
|
||||
if (!error) {
|
||||
try {
|
||||
const res = await axios.post(`/game/config`, cfg);
|
||||
if (res.status === 200) {
|
||||
notify({
|
||||
type: "success",
|
||||
text: "Changes saved!"
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Failed to save twitch cfg!", err);
|
||||
error = `Failed to save`;
|
||||
if (err?.response?.data?.error) {
|
||||
error = err.response.data.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
title={"Twitch Config"}
|
||||
desc="Setup your twitch application to enable game support."
|
||||
{onClose}
|
||||
>
|
||||
{#if error}
|
||||
<span class="error">{error}!</span>
|
||||
{/if}
|
||||
|
||||
<SettingsList>
|
||||
<a href="https://watcharr.app/docs/server_config/game-support-igdb" target="_blank">
|
||||
Learn how to configure this options at Watcharr Docs.
|
||||
</a>
|
||||
|
||||
<Setting title="Client ID" desc="Twitch application Client ID.">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="yrbrrakvgbce99fzjsidkfoalsk9eee"
|
||||
bind:value={cfg.clientId}
|
||||
disabled={formDisabled}
|
||||
/>
|
||||
</Setting>
|
||||
<Setting title="Client Secret" desc="Twitch application Client Secret.">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="yrbralsodkfishfzpajdkflqoefeee"
|
||||
bind:value={cfg.clientSecret}
|
||||
disabled={formDisabled}
|
||||
/>
|
||||
</Setting>
|
||||
<div class="btns">
|
||||
<button on:click={() => save()}>Save</button>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
a {
|
||||
text-decoration: underline;
|
||||
transition: opacity 100ms ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.btns {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
|
||||
:first-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
width: max-content;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: rgb(221, 48, 48);
|
||||
text-transform: capitalize;
|
||||
color: white;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
</style>
|
||||
@@ -26,6 +26,7 @@
|
||||
import axios from "axios";
|
||||
import { onMount } from "svelte";
|
||||
import RequestShow from "@/lib/request/RequestShow.svelte";
|
||||
import FollowedThoughts from "@/lib/content/FollowedThoughts.svelte";
|
||||
|
||||
export let data;
|
||||
|
||||
@@ -34,7 +35,9 @@
|
||||
let requestModalShown = false;
|
||||
let jellyfinUrl: string | undefined;
|
||||
|
||||
$: wListItem = $watchedList.find((w) => w.content.tmdbId === data.tvId);
|
||||
$: wListItem = $watchedList.find(
|
||||
(w) => w.content?.type === "tv" && w.content?.tmdbId === data.tvId
|
||||
);
|
||||
|
||||
let showId: number | undefined;
|
||||
let show: TMDBShowDetails | undefined;
|
||||
@@ -118,7 +121,7 @@
|
||||
<Title
|
||||
title={show.name}
|
||||
homepage={show.homepage}
|
||||
releaseDate={show.first_air_date}
|
||||
releaseYear={new Date(Date.parse(show.first_air_date)).getFullYear()}
|
||||
voteAverage={show.vote_average}
|
||||
voteCount={show.vote_count}
|
||||
/>
|
||||
@@ -176,12 +179,20 @@
|
||||
placeholder={`My thoughts on ${show.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 showId}
|
||||
<FollowedThoughts mediaType="tv" mediaId={showId} />
|
||||
{/if}
|
||||
|
||||
{#await getTvCredits()}
|
||||
<Spinner />
|
||||
{:then credits}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import SpinnerTiny from "@/lib/SpinnerTiny.svelte";
|
||||
import { unNotify } from "@/lib/util/notify";
|
||||
import { notifications } from "@/store";
|
||||
import { onMount } from "svelte";
|
||||
import { pwaInfo } from "virtual:pwa-info";
|
||||
|
||||
$: notifs = $notifications;
|
||||
|
||||
@@ -10,8 +12,31 @@
|
||||
`%cWATCHARR v${__WATCHARR_VERSION__}`,
|
||||
"background: white;color: black;font-size: large;padding: 3px 5px;"
|
||||
);
|
||||
|
||||
function resetTooltipPos() {
|
||||
const t = document.getElementById("tooltip");
|
||||
if (t) {
|
||||
t.style.top = "0";
|
||||
t.style.left = "0";
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("resize", resetTooltipPos);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", resetTooltipPos);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if pwaInfo?.webManifest?.linkTag}
|
||||
<!-- eslint-disable-next-line -->
|
||||
{@html pwaInfo.webManifest.linkTag}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<div id="tooltip" />
|
||||
<div id="notifications">
|
||||
{#each notifs as n}
|
||||
@@ -19,6 +44,8 @@
|
||||
{#if n.type === "loading"}
|
||||
<SpinnerTiny />
|
||||
{/if}
|
||||
<!-- only comes from our strings (which may have html) -->
|
||||
<!-- eslint-disable-next-line -->
|
||||
<span>{@html n.text}</span>
|
||||
<button
|
||||
class="plain"
|
||||
|
||||
@@ -1,40 +1,49 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type {
|
||||
FileWithData,
|
||||
Filters,
|
||||
Follow,
|
||||
ImportedList,
|
||||
PrivateUser,
|
||||
ServerFeatures,
|
||||
Theme,
|
||||
UserSettings,
|
||||
WLDetailedViewOption,
|
||||
Watched
|
||||
} from "./types";
|
||||
import type { Notification } from "./lib/util/notify";
|
||||
import { browser } from "$app/environment";
|
||||
import { toggleTheme } from "./lib/util/helpers";
|
||||
|
||||
export const defaultSort = ["DATEADDED", "DOWN"];
|
||||
|
||||
export const userInfo = writable<PrivateUser | undefined>();
|
||||
export const userSettings = writable<UserSettings | undefined>();
|
||||
export const watchedList = writable<Watched[]>([]);
|
||||
export const notifications = writable<Notification[]>([]);
|
||||
export const activeSort = writable<string[]>(["DATEADDED", "DOWN"]);
|
||||
export const activeSort = writable<string[]>(defaultSort);
|
||||
export const activeFilters = writable<Filters>({ type: [], status: [] });
|
||||
export const appTheme = writable<Theme>();
|
||||
export const importedList = writable<FileWithData | undefined>();
|
||||
export const importedList = writable<
|
||||
{ data: string; type: "text-list" | "tmdb" | "movary" } | undefined
|
||||
>();
|
||||
export const parsedImportedList = writable<ImportedList[] | undefined>();
|
||||
export const searchQuery = writable<string>("");
|
||||
export const serverFeatures = writable<ServerFeatures>();
|
||||
export const follows = writable<Follow[]>();
|
||||
export const wlDetailedView = writable<WLDetailedViewOption[]>([]);
|
||||
|
||||
export const clearAllStores = () => {
|
||||
watchedList.set([]);
|
||||
notifications.set([]);
|
||||
activeSort.set(["DATEADDED", "DOWN"]);
|
||||
activeSort.set(defaultSort);
|
||||
activeFilters.set({ type: [], status: [] });
|
||||
importedList.set(undefined);
|
||||
parsedImportedList.set(undefined);
|
||||
searchQuery.set("");
|
||||
userInfo.set(undefined);
|
||||
userSettings.set(undefined);
|
||||
follows.set([]);
|
||||
wlDetailedView.set([]);
|
||||
};
|
||||
|
||||
if (browser) {
|
||||
@@ -63,6 +72,11 @@ if (browser) {
|
||||
toggleTheme(defTheme);
|
||||
}
|
||||
|
||||
const wlDetailedViewR = localStorage.getItem("wlDetailedView");
|
||||
if (wlDetailedViewR) {
|
||||
wlDetailedView.update((v) => (v = JSON.parse(wlDetailedViewR)));
|
||||
}
|
||||
|
||||
// Save changes
|
||||
activeSort.subscribe((v) => {
|
||||
localStorage.setItem("activeFilter", JSON.stringify(v));
|
||||
@@ -75,4 +89,12 @@ if (browser) {
|
||||
appTheme.subscribe((v) => {
|
||||
localStorage.setItem("theme", v);
|
||||
});
|
||||
|
||||
wlDetailedView.subscribe((v) => {
|
||||
if (v) {
|
||||
localStorage.setItem("wlDetailedView", JSON.stringify(v));
|
||||
} else {
|
||||
localStorage.removeItem("wlDetailedView");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,10 +22,22 @@ export type Icon =
|
||||
| "chevron"
|
||||
| "search"
|
||||
| "sort"
|
||||
| "eye-closed";
|
||||
| "eye-closed"
|
||||
| "people"
|
||||
| "person"
|
||||
| "person-add"
|
||||
| "person-minus"
|
||||
| "pencil"
|
||||
| "eye"
|
||||
| "star"
|
||||
| "movary";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
export type WLDetailedViewOption = "statusRating" | "lastWatched" | "dateAdded" | "dateModified";
|
||||
export type ExtraDetails = { lastWatched: string; dateAdded: string; dateModified: string };
|
||||
export type ExtraDetailsGame = { dateAdded: string; dateModified: string };
|
||||
|
||||
export enum UserType {
|
||||
// Assume watcharr user if none of these...
|
||||
Jellyfin = 1
|
||||
@@ -52,6 +64,7 @@ export interface Activity extends dbModel {
|
||||
watchedId: number;
|
||||
type: string;
|
||||
data: string;
|
||||
customDate: string;
|
||||
}
|
||||
|
||||
export interface WatchedSeason {
|
||||
@@ -66,7 +79,8 @@ export interface Watched extends dbModel {
|
||||
id: number;
|
||||
watched: boolean;
|
||||
rating?: number;
|
||||
content: Content;
|
||||
content?: Content;
|
||||
game?: Game;
|
||||
activity: Activity[];
|
||||
status: WatchedStatus;
|
||||
thoughts: string;
|
||||
@@ -80,6 +94,12 @@ export interface WatchedAddRequest {
|
||||
status: WatchedStatus;
|
||||
}
|
||||
|
||||
export interface PlayedAddRequest {
|
||||
rating?: number;
|
||||
status?: WatchedStatus;
|
||||
igdbId?: number;
|
||||
}
|
||||
|
||||
export interface WatchedUpdateRequest {
|
||||
rating?: number;
|
||||
status?: WatchedStatus;
|
||||
@@ -100,17 +120,29 @@ export interface Profile {
|
||||
joined: Date;
|
||||
showsWatched: number;
|
||||
moviesWatched: number;
|
||||
moviesWatchedRuntime: number;
|
||||
showsWatchedRuntime: number;
|
||||
}
|
||||
|
||||
export interface UserSettings {
|
||||
private: boolean;
|
||||
privateThoughts: boolean;
|
||||
hideSpoilers: boolean;
|
||||
includePreviouslyWatched: boolean;
|
||||
}
|
||||
|
||||
export interface ChangePasswordForm {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
reEnteredNewPassword: string;
|
||||
}
|
||||
|
||||
// What the user search returns
|
||||
export interface PublicUser {
|
||||
id: number;
|
||||
username: string;
|
||||
avatar: Image;
|
||||
bio: string;
|
||||
}
|
||||
|
||||
// PrivateUser - Current users info
|
||||
@@ -118,6 +150,8 @@ export interface PrivateUser {
|
||||
username: string;
|
||||
type: UserType;
|
||||
permissions: UserPermission;
|
||||
avatar: Image;
|
||||
bio: string;
|
||||
}
|
||||
|
||||
export enum UserPermission {
|
||||
@@ -126,6 +160,12 @@ export enum UserPermission {
|
||||
PERM_REQUEST_CONTENT = 4
|
||||
}
|
||||
|
||||
export interface Image {
|
||||
createdAt: Date;
|
||||
blurHash: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface JellyfinFoundContent {
|
||||
hasContent: boolean;
|
||||
url: string;
|
||||
@@ -143,11 +183,6 @@ export interface TokenClaims {
|
||||
type: number;
|
||||
}
|
||||
|
||||
export interface FileWithData {
|
||||
file: File;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface TMDBContentDetails {
|
||||
id: number;
|
||||
backdrop_path: string;
|
||||
@@ -653,6 +688,18 @@ export interface ContentSearchPerson {
|
||||
popularity?: number;
|
||||
}
|
||||
|
||||
export interface GameSearch {
|
||||
id: number;
|
||||
cover: {
|
||||
id: number;
|
||||
image_id: string;
|
||||
};
|
||||
first_release_date: string;
|
||||
name: string;
|
||||
summary?: string;
|
||||
version_title?: string;
|
||||
}
|
||||
|
||||
export enum ImportResponseType {
|
||||
IMPORT_SUCCESS = "IMPORT_SUCCESS",
|
||||
IMPORT_FAILED = "IMPORT_FAILED",
|
||||
@@ -674,6 +721,11 @@ export interface ImportedList {
|
||||
year?: string;
|
||||
type?: ContentType;
|
||||
state?: string;
|
||||
rating?: number;
|
||||
ratingCustomDate?: Date;
|
||||
status?: WatchedStatus;
|
||||
thoughts?: string;
|
||||
datesWatched?: Date[];
|
||||
}
|
||||
|
||||
export interface Filters {
|
||||
@@ -687,6 +739,7 @@ export interface ServerConfig {
|
||||
TMDB_KEY: string;
|
||||
SONARR: SonarrSettings[];
|
||||
RADARR: RadarrSettings[];
|
||||
TWITCH: TwitchSettings;
|
||||
DEBUG: boolean;
|
||||
}
|
||||
|
||||
@@ -709,6 +762,11 @@ export interface RadarrSettings {
|
||||
automaticSearch?: boolean;
|
||||
}
|
||||
|
||||
export interface TwitchSettings {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export interface DropDownItem {
|
||||
id: number;
|
||||
value: string;
|
||||
@@ -778,4 +836,127 @@ export interface RadarrTestResponse {
|
||||
export interface ServerFeatures {
|
||||
sonarr: boolean;
|
||||
radarr: boolean;
|
||||
games: boolean;
|
||||
}
|
||||
|
||||
export interface Follow {
|
||||
createdAt: Date;
|
||||
followedUser: PublicUser;
|
||||
}
|
||||
|
||||
interface MovaryExportBase {
|
||||
title: string;
|
||||
year: string;
|
||||
tmdbId: string;
|
||||
imdbId: string;
|
||||
}
|
||||
|
||||
export interface MovaryHistory extends MovaryExportBase {
|
||||
watchedAt: string;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface MovaryRatings extends MovaryExportBase {
|
||||
userRating: string;
|
||||
}
|
||||
|
||||
export interface MovaryWatchlist extends MovaryExportBase {
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export interface Game {
|
||||
id: number;
|
||||
updatedAt: string;
|
||||
igdbId: number;
|
||||
name: string;
|
||||
coverId: string;
|
||||
summary: string;
|
||||
storyline: string;
|
||||
releaseDate?: string;
|
||||
rating: number;
|
||||
ratingCount: number;
|
||||
status: number;
|
||||
category: number;
|
||||
poster?: Image;
|
||||
}
|
||||
|
||||
export interface GameDetailsResponse {
|
||||
id: number;
|
||||
artworks: {
|
||||
width: number;
|
||||
height: number;
|
||||
image_id: string;
|
||||
}[];
|
||||
category: number;
|
||||
cover: {
|
||||
id: number;
|
||||
image_id: string;
|
||||
};
|
||||
first_release_date: number;
|
||||
game_modes: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
genres: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
involved_companies: {
|
||||
id: number;
|
||||
company: {
|
||||
id: number;
|
||||
description: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
websites: {
|
||||
id: number;
|
||||
category: number;
|
||||
trusted: boolean;
|
||||
url: string;
|
||||
}[];
|
||||
};
|
||||
developer: boolean;
|
||||
porting: boolean;
|
||||
publisher: boolean;
|
||||
supporting: boolean;
|
||||
}[];
|
||||
name: string;
|
||||
platforms: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
rating: number;
|
||||
rating_count: number;
|
||||
summary: string;
|
||||
storyline: string;
|
||||
url: string;
|
||||
videos: {
|
||||
id: number;
|
||||
name: string;
|
||||
video_id: string;
|
||||
}[];
|
||||
websites: {
|
||||
id: number;
|
||||
category: number;
|
||||
trusted: boolean;
|
||||
url: string;
|
||||
}[];
|
||||
similar_games: {
|
||||
id: number;
|
||||
name: string;
|
||||
summary: string;
|
||||
first_release_date: number;
|
||||
cover: {
|
||||
id: number;
|
||||
image_id: string;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
export enum GameWebsiteCategory {
|
||||
Official = 1,
|
||||
Wikipedia = 3,
|
||||
Twitch = 6,
|
||||
Steam = 13,
|
||||
Reddit = 14
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ $backdrop-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 80%, rgba(0, 0
|
||||
$nav-color: var(--nav-color);
|
||||
$nav-height: 71px; // How tall the nav is naturally, usefull in some places.
|
||||
$poster-rating-color: var(--poster-rating-color);
|
||||
$poster-extra-detail-bg-color: rgba(46, 46, 46, 0.5);
|
||||
$error: #f3555a;
|
||||
$success: #28a745;
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 7.2 KiB |