Compare commits

...

311 Commits

Author SHA1 Message Date
IRHM 2a5a470347 v1.33.0 2024-02-12 00:33:07 +00:00
IRHM 07e3f5f7a8 1.33.0-dev 2024-02-11 23:44:07 +00:00
Mr 1b83c70b5e Merge pull request #344 from sbondCo/twitch-config-modal-doc-link
TwitchModal: Add link to setup docs.
2024-02-11 23:41:09 +00:00
IRHM 1e5cdc151b TwitchModal: Add link to setup docs. 2024-02-11 23:37:53 +00:00
Mr dcb21a6e1e Merge pull request #343 from sbondCo/game-docs
Game docs
2024-02-11 23:30:41 +00:00
IRHM 0b4f3d5c97 Create game setup guide
i just realised screenshots are in dark mode, not matching the others in another guide, but it's too late to change em cuz what's done is done.
2024-02-11 23:29:14 +00:00
IRHM 0d8ea359e4 doc: Update docusaurus to v3 2024-02-11 22:41:51 +00:00
Mr 1c44b1812a Merge pull request #342 from sbondCo/games
Games
2024-02-11 21:56:58 +00:00
IRHM 3d5cf70ec0 FilterMenu: Change held to on hold 2024-02-10 20:26:35 +00:00
IRHM f1f26d0f57 Dockerfile: Copy game module 2024-02-10 19:54:18 +00:00
IRHM e3fe047afd follow: Fix get watched item logic for followed thoughts
Before it could get two entries if user has two entries that had same id number for content and game ids.
2024-02-10 17:50:10 +00:00
IRHM 4afc32f9d1 FilterMenu: Fix type styling 2024-02-10 17:49:10 +00:00
IRHM 7ea78f2d57 watched: preload game when getting public list 2024-02-10 17:31:10 +00:00
IRHM 6092410b20 Followed thoughts for games 2024-02-10 17:28:29 +00:00
IRHM 8f5293a769 Remove game filter if games disabled, update status filters for games 2024-02-10 16:48:25 +00:00
IRHM 6d25356d30 WatchedList: Fix type filtering 2024-02-10 16:18:14 +00:00
IRHM dbd86b89c7 FilterMenu: Add Game type 2024-02-10 16:18:07 +00:00
IRHM bf0b4fbb6a game: remove log of credentials 2024-02-10 15:56:35 +00:00
IRHM 5585053b85 igdb token refresh logic 2024-02-10 15:53:30 +00:00
IRHM a61b82e9af Search: Fix if games, catch search requests separately 2024-02-08 22:55:30 +00:00
IRHM d4fe6d3653 Search: Remove unused interface, remove comment 2024-02-08 22:45:30 +00:00
IRHM 6d2ab6e7e4 Search: Only search movies/tv if games disabled 2024-02-08 22:44:46 +00:00
IRHM c5e6ae702a Poster: Don't try loading image unless we have a poster path 2024-02-08 22:44:27 +00:00
IRHM 2a9b0443f5 Tv: Fix wListItem find 2024-02-08 00:10:55 +00:00
IRHM e1e933af16 Fix Status 2024-02-08 00:08:39 +00:00
IRHM a5fbecd32a Remove unneeded comments 2024-02-07 23:54:32 +00:00
IRHM 98006efb03 Games: Fix similar games having wrong status/rating 2024-02-07 23:49:11 +00:00
IRHM 926b0f8267 GamePoster: Use blurhash when available for poster loader 2024-02-07 23:46:59 +00:00
IRHM cc3f21aa93 Cache game covers 2024-02-07 22:47:16 +00:00
IRHM e311bcd901 Display status differently for games
Created toUnderstandableStatus and toUnderstandableWatchedStatus helpers to convert statuses into readable.
2024-02-06 23:56:49 +00:00
IRHM eb66baba80 Routes: Remove testing cache exp time for game routes 2024-02-03 21:41:26 +00:00
IRHM 09795b4da3 Game: Display similar games 2024-02-03 21:41:03 +00:00
IRHM 8088184e6b Create getPlayedDependedProps for searched game posters 2024-02-03 21:09:04 +00:00
IRHM 1b6c56dd8f sorta finalize gameposter on own watched list 2024-02-03 21:00:26 +00:00
IRHM 86195eb682 Game: Add check before trying to iterate over genres and modes
and show 'Unknown' message if we have none available
2024-02-03 20:38:12 +00:00
IRHM f40ffa124c GamePoster: Accept generic props so we can reuse on watched list 2024-02-03 20:38:12 +00:00
IRHM 704b6380c7 Game: Use cover if no artwork available for backdrop 2024-02-03 20:38:12 +00:00
IRHM b434665caf fix content undefined issues 2024-02-03 20:38:12 +00:00
IRHM 3554d4bc60 updating games watched item 2024-02-03 20:38:12 +00:00
IRHM 917ddd5401 fix profile stats
since Content pointer stored now
2024-02-03 20:38:12 +00:00
IRHM e7cf21fdb2 basic adding games to list 2024-02-03 20:33:52 +00:00
IRHM c932bd188a basic game detail page 2024-02-03 20:32:44 +00:00
IRHM e5d17bee81 game search ep 2024-02-03 20:31:45 +00:00
IRHM 65836a1654 idk if this works
bakup the beginnings of game support
2024-02-03 20:31:45 +00:00
IRHM 0f7bdaffbb v1.32.1 2024-02-03 15:16:09 +00:00
IRHM 30dae4c5c9 1.32.1-dev1 2024-02-03 14:57:28 +00:00
Mr f67d1641db Merge pull request #336 from sbondCo/awesome-stuff-too
Fix movary importing and include previously watched filter
2024-02-03 14:54:39 +00:00
IRHM 0ff02e00c9 WatchedList: Fix type filter when finished status enabled 2024-02-03 13:37:24 +00:00
IRHM 194081ac2f Profile: get profile stats again after updating includepreviouslywatched setting 2024-02-03 13:34:24 +00:00
IRHM 896f80ebd6 profile: Include previously watched in stats if enabled by user 2024-02-03 13:16:55 +00:00
IRHM 862279e914 import: Fix customDates being the same on all dateswatched activity
pointer value being updated each iteration, so we end up using same date for all object in the array
2024-02-03 12:51:17 +00:00
IRHM bdf5cf54af 1.32.1-dev 2024-02-03 02:53:16 +00:00
Mr 4323ab7086 Merge pull request #335 from sbondCo/amazing-stuff
https://github.com/sbondCo/Watcharr/issues/332#issuecomment-1923230972
2024-02-03 02:51:26 +00:00
IRHM 555b237ed0 WatchedList: Filter on sort change, add STATUS_CHANGED as activity that could make show watched 2024-02-03 02:37:58 +00:00
IRHM 5146af2672 Filtering: Add includePreviouslyWatched setting 2024-02-03 02:13:46 +00:00
IRHM 4d3da51ae7 Movary Import: Keep history data if show is also on watchlist 2024-02-02 21:35:54 +00:00
IRHM 9cba877fe6 Movary Import: Set latest watched date as watched entry CreatedAt 2024-02-02 21:21:21 +00:00
IRHM 924ffcb33f v1.32.0 2024-02-02 02:24:50 +00:00
IRHM b638062d36 1.32.0-dev 2024-02-02 01:47:20 +00:00
dependabot[bot] da64b5ca59 ui: bump svelte-check from 3.6.2 to 3.6.3 (#317)
Bumps [svelte-check](https://github.com/sveltejs/language-tools) from 3.6.2 to 3.6.3.
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check-3.6.2...svelte-check-3.6.3)

---
updated-dependencies:
- dependency-name: svelte-check
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 01:30:51 +00:00
dependabot[bot] 8f2d9b0984 ui: bump @typescript-eslint/parser from 6.18.0 to 6.20.0 (#328)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 6.18.0 to 6.20.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.20.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 01:30:34 +00:00
dependabot[bot] c2e85ff8b3 server: bump gorm.io/gorm from 1.25.5 to 1.25.6 in /server (#327)
Bumps [gorm.io/gorm](https://github.com/go-gorm/gorm) from 1.25.5 to 1.25.6.
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.25.5...v1.25.6)

---
updated-dependencies:
- dependency-name: gorm.io/gorm
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 01:29:18 +00:00
dependabot[bot] 3c9d074169 ui: bump @typescript-eslint/eslint-plugin from 6.18.0 to 6.20.0 (#330)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 6.18.0 to 6.20.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.20.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 01:28:38 +00:00
dependabot[bot] 7a254222a1 ui: bump @sveltejs/adapter-node from 2.0.2 to 4.0.1 (#331)
Bumps [@sveltejs/adapter-node](https://github.com/sveltejs/kit/tree/HEAD/packages/adapter-node) from 2.0.2 to 4.0.1.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/adapter-node/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/adapter-node@4.0.1/packages/adapter-node)

---
updated-dependencies:
- dependency-name: "@sveltejs/adapter-node"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 01:26:35 +00:00
dependabot[bot] 4662977677 ui: bump prettier from 3.1.1 to 3.2.4 (#322)
Bumps [prettier](https://github.com/prettier/prettier) from 3.1.1 to 3.2.4.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.1.1...3.2.4)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 01:25:01 +00:00
dependabot[bot] 798c80f69b ui: bump vite from 5.0.11 to 5.0.12 (#320)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.0.11 to 5.0.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.0.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 01:24:29 +00:00
Mr d8e4182824 Merge pull request #334 from sbondCo/movary-import
Movary Importing
2024-02-02 01:21:49 +00:00
IRHM ac9fb8c28d import/process: Update importText for TMDb to TMDB 2024-02-02 01:11:32 +00:00
IRHM d0b1b9ed17 import: add error to sucimp log 2024-02-02 00:56:16 +00:00
IRHM 548643327d Support importing status, thoughts and datesWatched
for movary import
2024-02-02 00:45:36 +00:00
IRHM a86d2a218a wip build movary import list 2024-02-02 00:18:41 +00:00
IRHM 21ecc4cbbf wip movary importing from the 3 export files 2024-02-01 01:32:01 +00:00
Mr 2e19cad3a8 Merge pull request #314 from sbondCo/update-cache-on-req
Update content table on request
2024-01-13 22:47:48 +00:00
IRHM 8bffc7380e saveContent: remove test log and debug on Updates
and update its error handling, copied from below, removed case not needed for updating... or even that code anymore but whatevz
2024-01-13 22:44:02 +00:00
IRHM 96720caace saveContent: Support onlyUpdate parameter 2024-01-13 17:40:33 +00:00
IRHM 6c130952d7 update cache when content is freshly fetched 2024-01-11 02:29:46 +00:00
IRHM 3c1fc0e950 split out content caching for reuse 2024-01-11 02:05:35 +00:00
IRHM d95990fc56 v1.31.1 2024-01-09 20:45:59 +00:00
dependabot[bot] 9d0a433121 Bump follow-redirects from 1.15.3 to 1.15.4 in /doc (#311)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.3 to 1.15.4.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.3...v1.15.4)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-09 20:44:50 +00:00
Mr 2bd02439c2 Support ARM64 Arch (#310)
* dev workflow: Add arm64 platform build

in sequence because paralellising the jobs looks hard

* dev action: remove `dev` tag

I think it would be better to just refer to them with their sha256 instead + i dont like that when a `dev` image is built, the `dev` tag goes to top of list of tagged images

* Revert "dev action: remove `dev` tag"

This reverts commit 0179f616fa.

nevermind, that is not a thing.

* release action: add arm platform
2024-01-09 20:42:30 +00:00
dependabot[bot] 6a1fdd69e3 ui: bump @sveltejs/kit from 2.0.7 to 2.0.8 (#308)
Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.0.7 to 2.0.8.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.0.8/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-08 20:35:40 +00:00
dependabot[bot] fb7dc5d0f8 ui: bump axios from 1.6.3 to 1.6.5 (#304)
Bumps [axios](https://github.com/axios/axios) from 1.6.3 to 1.6.5.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.3...v1.6.5)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-08 20:34:12 +00:00
dependabot[bot] c3d480d47b ui: bump @sveltejs/kit from 2.0.6 to 2.0.7 (#305)
Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.0.6 to 2.0.7.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.0.7/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-08 20:33:39 +00:00
dependabot[bot] 274c97bcdd ui: bump @typescript-eslint/eslint-plugin from 6.17.0 to 6.18.0 (#306)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 6.17.0 to 6.18.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.18.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-08 20:33:20 +00:00
dependabot[bot] d6b5fc9cd3 server: bump golang.org/x/crypto from 0.17.0 to 0.18.0 in /server (#303)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.17.0 to 0.18.0.
- [Commits](https://github.com/golang/crypto/compare/v0.17.0...v0.18.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-08 20:32:51 +00:00
dependabot[bot] 4de9865a39 ui: bump vite from 5.0.10 to 5.0.11 (#307)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.0.10 to 5.0.11.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.0.11/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-08 20:32:22 +00:00
Mr d278f2cf98 Merge pull request #302 from sbondCo/watchtime
Watchtime Profile Stat
2024-01-07 23:41:21 +00:00
IRHM 5aec83c2f0 Profile: Badly implement show watchtime
for now... hopefully
2024-01-07 23:36:56 +00:00
IRHM fbda3d4b9b Stat: Add disclaimer prop 2024-01-07 23:15:52 +00:00
IRHM dde8fad248 Profile: Implement movies watched runtime stat 2024-01-07 21:52:26 +00:00
IRHM e8309568bc v1.31.0 2024-01-07 17:50:51 +00:00
IRHM 997e3afb7e 1.31.0-dev 2024-01-07 01:07:39 +00:00
Mr 891fc51bcd Input changed validation (#299)
* thoughts change validation

* Rating & Status change validation

* Poster: status change validation
2024-01-07 01:04:00 +00:00
dependabot[bot] ebe42f1d34 ui: bump @typescript-eslint/parser from 6.16.0 to 6.18.0 (#298)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 6.16.0 to 6.18.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.18.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-06 18:43:04 +00:00
Mr b799c660ca Merge pull request #294 from sbondCo/dependabot/npm_and_yarn/typescript-eslint/eslint-plugin-6.17.0
ui: bump @typescript-eslint/eslint-plugin from 6.16.0 to 6.17.0
2024-01-06 18:41:14 +00:00
Mr a10f570983 Merge pull request #293 from sbondCo/dependabot/npm_and_yarn/axios-1.6.3
ui: bump axios from 1.6.2 to 1.6.3
2024-01-06 18:40:51 +00:00
Mr f205f96010 Merge pull request #297 from sbondCo/detail-view
Detail view
2024-01-06 18:39:49 +00:00
IRHM 735a98e0b2 Poster: Make extra details box more opaque
and use same color for both themes
2024-01-06 17:45:47 +00:00
IRHM f87aa70a3b Poster: fix extra details colors and spacing 2024-01-06 17:41:13 +00:00
IRHM a2dc418ee6 Poster: Hide empty extra-details 2024-01-05 19:35:39 +00:00
IRHM 4c7982f7be improve getLatestWatchedSeason
it could be improved more though
2024-01-05 06:17:41 +00:00
IRHM 43d603c212 Detailed Poster: Use real data 2024-01-05 05:39:49 +00:00
IRHM ebf623ed1b Redesign poster extra details and allow configuring displayed data 2024-01-05 04:53:00 +00:00
IRHM d925519beb Create DetailedPoster 2024-01-03 23:13:38 +00:00
dependabot[bot] 07b72452e8 ui: bump @typescript-eslint/eslint-plugin from 6.16.0 to 6.17.0
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 6.16.0 to 6.17.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.17.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-01 19:22:43 +00:00
dependabot[bot] ab4781bec0 ui: bump axios from 1.6.2 to 1.6.3
Bumps [axios](https://github.com/axios/axios) from 1.6.2 to 1.6.3.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.2...v1.6.3)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-01 19:21:56 +00:00
IRHM 7719b6336c v1.30.0 2024-01-01 02:50:19 +00:00
IRHM e749e94bfb 1.30.0-dev 2023-12-31 20:23:17 +00:00
IRHM c1ceb17ada Rating: Fix text not updating if network request resolves after we hoverEnd 2023-12-31 20:18:39 +00:00
IRHM 91dd00b556 routes: Set AuthRequired middleware once for auth routes that require it
indenting authd routes definitely helps with readability
2023-12-30 17:37:59 +00:00
Mr 60e873b4c0 Merge pull request #292 from sbondCo/content-stars-text
Content rating text follow hovered star
2023-12-30 05:02:28 +00:00
IRHM 4333591c9c Rating: Add transition to transform
to make it smoother when moving to initial close rating to default middle pos.
2023-12-30 04:53:10 +00:00
IRHM 462a46aabb Rating: Fix causing page overflow on small viewports 2023-12-30 04:52:15 +00:00
IRHM c766d68d49 Rating: Center desc text and make it follow hovered star 2023-12-29 03:04:56 +00:00
Mr 6b7c38aa7a Merge pull request #291 from sbondCo/exportwl
Export Watched List
2023-12-29 02:08:48 +00:00
IRHM 367c79f29c Change empty watch list error text 2023-12-29 02:07:35 +00:00
IRHM c59eb82a1a dont try saving an empty watch list 2023-12-29 02:03:46 +00:00
IRHM 3d40e5554f change suggested export name to watcharr-export.json 2023-12-29 01:58:49 +00:00
IRHM 75f091cfa0 Basic exporting watched list support 2023-12-29 01:55:07 +00:00
Mr 1d0abe4982 Merge pull request #290 from iamericfletcher/user-password-change-issue-279
Implement Change Password Feature (Issue #279)
2023-12-28 22:24:01 +00:00
Mr f8490481a5 Merge branch 'dev' into user-password-change-issue-279 2023-12-28 22:16:54 +00:00
IRHM 1adc56f965 userChangePassword: Select only pass from db 2023-12-28 22:12:50 +00:00
IRHM 8861233aaa Move GetPassArgonParams, Remove DEBUG, add use of ternary operator 2023-12-28 22:01:58 +00:00
iamericfletcher 3c4718bc45 Update error handling in changeUserPassword and display of error messages in pwChangeModal
The 'changeUserPassword' callback is updated such that error messages are passed back to the calling function in `PwChangeModal` allowing errors to show up in the same place.
2023-12-28 16:19:31 -05:00
iamericfletcher f6fa9e7666 Refactor ArgonParams to a function for register and userChangePassword in auth.go 2023-12-28 15:37:11 -05:00
iamericfletcher 98a7c370a8 Removed unneeded else branches in auth.go 2023-12-28 14:59:57 -05:00
iamericfletcher 37f9bffb13 Move server endpoint to /auth/change_password and update corresponding frontend API call 2023-12-28 14:52:19 -05:00
iamericfletcher a4573fe52a Move userChangePassword function to auth.go 2023-12-28 14:40:35 -05:00
iamericfletcher aeaaccda88 Remove Form.svelte and related import
Form.svelte was deleted due to not being used or needed. An associated import line in PwChangeModal.svelte has also been removed.

Additionally, setting parameter DEBUG was removed from server/config.go.
2023-12-27 17:48:18 -05:00
iamericfletcher 3a2ce0c718 Refactor password change feature and update debug messages
Removed a substantial block of commented-out code related to password changing in the `user.go` file, which simplifies the structure and readability of the code. Improved clarity and specificity of console debug/feedback messages throughout the frontend, ensuring easier debugging. Commit also includes minor code clean up in some files.
2023-12-27 17:24:05 -05:00
iamericfletcher 2ac80d0b8c Refine password change error handling and reporting
The commit delivers improved error handling and debugging information for password change functionality. Error messages now provide more detailed context on failures. Additionally, new password hashing has been added in the password change process. The user interface has also been updated to reflect specific error messages returned from the API.
2023-12-27 14:14:53 -05:00
iamericfletcher 3ff5d9836b Refactor user password change functionality
The password change function now involves first querying user data from the database, before checking the validity of their old password. Logic has been updated on both frontend and backend code. Errors have been added for handling instances where password comparison, user retrieval or other processes fail.
2023-12-27 10:08:06 -05:00
iamericfletcher e6d486c2a2 Implement password change feature
A new feature has been added that allows users to change their password. This includes updates in several different files to accommodate this new functionality. Changes include adding a new API endpoint, creating a new function to handle the password change request, and removing the username field from the ChangePasswordForm interface.
2023-12-26 20:43:11 -05:00
iamericfletcher c4e591e2f2 Improve form validation in PwChangeModal.svelte
Updated form validation to check for missing inputs and ensure consistency between new password and re-entered new password. Enhanced logging for better status tracking and error reporting during form validation process.
2023-12-26 12:36:32 -05:00
iamericfletcher 0eb1a88103 Refactoring password button in PwChangeModal to be in line with button in SonarrModal 2023-12-26 11:58:47 -05:00
IRHM ffe6afe2d2 v1.29.0 2023-12-26 14:46:15 +00:00
iamericfletcher c767572905 Refactoring password change modal form 2023-12-26 09:36:24 -05:00
IRHM da803220e4 1.29.0-dev1 2023-12-26 14:23:57 +00:00
IRHM 5c4f023fd8 FollowedThoughts: Fix dark theme status icon fill 2023-12-26 14:23:31 +00:00
IRHM 7e2e64f394 1.29.0-dev 2023-12-26 14:14:07 +00:00
dependabot[bot] 9609909122 ui: bump @typescript-eslint/eslint-plugin from 6.13.0 to 6.16.0 (#285)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 6.13.0 to 6.16.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.16.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-26 14:09:02 +00:00
dependabot[bot] 7522784f18 ui: bump @typescript-eslint/parser from 6.15.0 to 6.16.0 (#284)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 6.15.0 to 6.16.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.16.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-26 14:07:09 +00:00
dependabot[bot] 8d5379e38b ui: bump svelte-preprocess from 5.1.1 to 5.1.3 (#283)
Bumps [svelte-preprocess](https://github.com/sveltejs/svelte-preprocess) from 5.1.1 to 5.1.3.
- [Changelog](https://github.com/sveltejs/svelte-preprocess/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte-preprocess/compare/v5.1.1...v5.1.3)

---
updated-dependencies:
- dependency-name: svelte-preprocess
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-26 14:06:43 +00:00
dependabot[bot] 8065349a5c ui: bump eslint from 8.55.0 to 8.56.0 (#286)
Bumps [eslint](https://github.com/eslint/eslint) from 8.55.0 to 8.56.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.55.0...v8.56.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-26 14:04:50 +00:00
dependabot[bot] 26c84ae7c7 ui: bump prettier from 3.1.0 to 3.1.1 (#287)
Bumps [prettier](https://github.com/prettier/prettier) from 3.1.0 to 3.1.1.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.1.0...3.1.1)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-26 14:04:07 +00:00
Mr f190bffdd0 Merge pull request #288 from sbondCo/public-thoughts
Public thoughts
2023-12-26 06:36:08 +00:00
IRHM e0f8d753bf follow: remove debug log 2023-12-26 06:27:22 +00:00
IRHM b6223c7ac8 FollowedThoughts: Ensure status icon is always the same size 2023-12-26 06:08:12 +00:00
IRHM ffd8bbe442 expose new privateThoughts setting to user 2023-12-26 05:45:54 +00:00
IRHM bc86b8f6b4 FollowedThoughts: Add extra space between status and rating 2023-12-26 05:26:12 +00:00
IRHM 0cb5c01b5e FollowedThoughts: Add status icon to cards 2023-12-26 05:22:39 +00:00
IRHM 0393966ecc getting and showing followed thoughts on content
Show followed users rating and thoughts on content
2023-12-26 05:04:41 +00:00
iamericfletcher b3ba00cd0d Added beginning stages of password change functionality in user profile
A new interface ChangePasswordForm has been created to handle password change form data.

The 'PwChangeModal.svelte' modal allows the user to input current and new passwords for a change password request.

The user profile page has been updated to include a "Change Password" button which triggers the change password modal.
2023-12-25 21:39:54 -05:00
Mr 5084a5b724 Merge pull request #281 from iamericfletcher/feature-sort-filter-watched-lists
Enhancement: Allow filtering and sorting other watched lists #276
2023-12-24 23:43:30 +00:00
iamericfletcher 43a484f949 Remove unused 'watchedListErrorOccurred' 2023-12-24 12:59:11 -05:00
iamericfletcher 8434e852ce Enhancement: Allow filtering and sorting other watched lists #276
Added a new store variable 'watchedListErrorOccurred' set to true if `getPublicWatchedList()` promise errors.

The aim is that the filter and sort functionality only show if the watched list loads in successfully.
2023-12-24 09:02:35 -05:00
Mr e541dc5cdb Merge pull request #280 from sbondCo/fix-dupe-tmdbids
Fix content with same tmdb ids
2023-12-23 20:29:41 +00:00
IRHM 4a20819803 addWatched: Check content type when fetching 2023-12-23 20:23:22 +00:00
IRHM 9a568b9da7 add content type to .find of wListItem in content pages 2023-12-23 20:15:13 +00:00
dependabot[bot] 9176d97eee ui: bump @sveltejs/kit from 1.27.6 to 2.0.6 (#278)
* ui: bump @sveltejs/kit from 1.27.6 to 2.0.6

Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 1.27.6 to 2.0.6.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.0.6/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* update @sveltejs/adapter-node, @vite-pwa/sveltekit, vite

to fix peer dep issues

* import vitePreprocess from @sveltejs/vite-plugin-svelte

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: IRHM <37304121+IRHM@users.noreply.github.com>
2023-12-23 16:26:06 +00:00
dependabot[bot] 1cbc065b7b ui: bump eslint-plugin-svelte from 2.35.0 to 2.35.1 (#263)
Bumps [eslint-plugin-svelte](https://github.com/sveltejs/eslint-plugin-svelte) from 2.35.0 to 2.35.1.
- [Release notes](https://github.com/sveltejs/eslint-plugin-svelte/releases)
- [Changelog](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/eslint-plugin-svelte/compare/v2.35.0...v2.35.1)

---
updated-dependencies:
- dependency-name: eslint-plugin-svelte
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-23 15:55:52 +00:00
dependabot[bot] b4865aa789 ui: bump prettier-plugin-svelte from 3.0.3 to 3.1.2 (#262)
Bumps [prettier-plugin-svelte](https://github.com/sveltejs/prettier-plugin-svelte) from 3.0.3 to 3.1.2.
- [Changelog](https://github.com/sveltejs/prettier-plugin-svelte/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/prettier-plugin-svelte/compare/v3.0.3...v3.1.2)

---
updated-dependencies:
- dependency-name: prettier-plugin-svelte
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-23 15:55:31 +00:00
dependabot[bot] 0c29927ef6 workflow: bump actions/setup-node from 3 to 4 (#271)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3 to 4.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-23 15:54:54 +00:00
dependabot[bot] 75e499a070 ui: bump typescript from 5.3.2 to 5.3.3 (#264)
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.3.2 to 5.3.3.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v5.3.2...v5.3.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-23 15:54:26 +00:00
dependabot[bot] fe589e7de5 workflow: bump actions/checkout from 3 to 4 (#272)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-23 15:50:41 +00:00
dependabot[bot] 9d7d6b0e3a ui: bump @typescript-eslint/parser from 6.13.1 to 6.15.0 (#274)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 6.13.1 to 6.15.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.15.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-23 15:49:00 +00:00
Mr 5fb720cca4 Merge pull request #275 from sbondCo/dependabot/go_modules/server/golang.org/x/crypto-0.17.0
server: bump golang.org/x/crypto from 0.16.0 to 0.17.0 in /server
2023-12-23 15:48:20 +00:00
Mr 2084b42098 Merge pull request #277 from sbondCo/fix-tooltips
Fix tooltips on scroll and from causing overflow on window resize
2023-12-23 15:43:52 +00:00
IRHM d26db7759f resetTooltipPos on window resize to fix overflow
resizing window smaller if tooltip is somewhere can cause overflow on page, reset its pos to top left on resize to avoid this.
2023-12-23 15:40:59 +00:00
IRHM 89311d4487 Revert "make tooltips fixed position"
This reverts commit 4d65a16584.
2023-12-23 15:36:29 +00:00
dependabot[bot] 72dca29d69 server: bump golang.org/x/crypto from 0.16.0 to 0.17.0 in /server
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.16.0 to 0.17.0.
- [Commits](https://github.com/golang/crypto/compare/v0.16.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-18 19:50:49 +00:00
imgbot[bot] 36b3bde00e [ImgBot] Optimize images (#268)
*Total -- 5,368.58kb -> 4,578.90kb (14.71%)

/static/logo-sqre.png -- 10.43kb -> 4.68kb (55.15%)
/screenshot/user-profile.png -- 47.51kb -> 36.22kb (23.78%)
/screenshot/content-details-page-dark.png -- 523.03kb -> 429.56kb (17.87%)
/screenshot/content-details-page.png -- 370.65kb -> 304.62kb (17.81%)
/static/logo-sqre-144.png -- 6.43kb -> 5.39kb (16.28%)
/static/logo-sqre-192.png -- 8.61kb -> 7.24kb (15.97%)
/screenshot/homepage-poster-hover.png -- 877.03kb -> 751.92kb (14.27%)
/screenshot/homepage-poster-change-status.png -- 865.78kb -> 743.03kb (14.18%)
/screenshot/discover-page.png -- 765.65kb -> 660.56kb (13.73%)
/screenshot/homepage.png -- 945.27kb -> 816.18kb (13.66%)
/screenshot/homepage-dark.png -- 948.18kb -> 819.52kb (13.57%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
Co-authored-by: ImgBotApp <ImgBotHelp@gmail.com>
2023-12-10 17:34:04 +00:00
IRHM 425f0da422 v1.28.0 2023-12-10 03:02:38 +00:00
IRHM 6773736113 1.28.0-dev4 2023-12-10 02:57:23 +00:00
IRHM 51ec248b67 add short_name to pwa manifest 2023-12-10 02:57:00 +00:00
IRHM 702084c83c 1.28.0-dev3 2023-12-10 02:51:51 +00:00
IRHM 2aa5f0c4fa Add name, description, bg col and category to pwa manifest config
the lowercase name is too annoying, must have the capital W. Hopefully the bg colour can fix issues where the icon isn't scaled up, but white was shown so it looked odd. That may mask the issue.
2023-12-10 02:51:22 +00:00
IRHM 359f5a372a 1.28.0-dev2 2023-12-10 02:34:53 +00:00
Mr 03a8397498 Merge pull request #267 from sbondCo/new-pwa-icons
new icons for pwa
2023-12-10 02:34:02 +00:00
IRHM 034e6b10e7 new icons for pwa 2023-12-10 02:31:38 +00:00
IRHM 8100c5f9a9 1.28.0-dev1 2023-12-09 23:23:43 +00:00
Mr 95c8f2d5b0 Merge pull request #266 from sbondCo/prs
PR Template and test workflow
2023-12-09 23:15:35 +00:00
IRHM be29170f6d Create test-pr.yml
to check for formatting / linting errors
2023-12-09 23:12:59 +00:00
IRHM f02699b089 fix @html linting errors
the contents come from trusted sources so we should be okay
2023-12-09 23:08:37 +00:00
IRHM 82b7a3d0f6 fix css linting errors 2023-12-09 22:57:46 +00:00
IRHM 8608ad4fd6 format readme 2023-12-09 22:51:17 +00:00
IRHM ab96107797 Create PULL_REQUEST_TEMPLATE.md 2023-12-09 22:47:19 +00:00
Mr a38291c9b9 Merge pull request #260 from sbondCo/dokrfl
Fix dockerfile server build
2023-12-09 22:43:28 +00:00
IRHM 67cfd03a1d run formatter 2023-12-09 22:40:18 +00:00
IRHM c4fa99b5aa Fix dockerfile server build for musl
Workaround from gh issue seems to fix the issue of build failing
2023-12-09 22:28:22 +00:00
Mr 1d904a7188 Merge pull request #259 from ishaanparlikar/feature/pwa
Feature/pwa
2023-12-09 19:31:03 +00:00
ishaanparlikar be8cee5378 removed jsdoc type inference 2023-12-10 00:31:47 +05:30
ishaanparlikar 128a3d4cc6 removed workbox dependancy 2023-12-10 00:29:51 +05:30
ishaanparlikar 610fb3d0c0 fixed indentation 2023-12-09 12:06:59 +05:30
ishaanparlikar 2dd01f296b Feature: Added PWA support for Watcharr #225 2023-12-09 12:02:20 +05:30
Mr 6e118085a9 Merge pull request #258 from sbondCo/episode-overview
SeasonsListEpisode: Fix spoilers
2023-12-09 02:21:26 +00:00
IRHM 2f3400d4d7 SeasonsListEpisode: Fix spoilers 2023-12-09 02:20:53 +00:00
Mr da787741d8 Merge pull request #257 from sbondCo/episode-overview
Episode overview and runtime
2023-12-09 02:16:51 +00:00
IRHM 97bdf2f89c SeasonsListEpisode: Quick fix patch hack code and reimplement fix and repair the star position to fix it 2023-12-09 02:15:30 +00:00
IRHM a8c8431715 SeasonsListEpisode: Add episode runtimes 2023-12-09 02:11:15 +00:00
IRHM 18e3e333c6 SeasonsListEpisode: Add overviews 2023-12-09 02:01:29 +00:00
Mr f7ddb1e14b Merge pull request #255 from ishaanparlikar/fix/#217
Hiding rating when hide spoiler is checked in profile
2023-12-08 22:34:32 +00:00
ishaanparlikar e5c513161e Merge branch 'dev' of https://github.com/ishaanparlikar/Watcharr into dev 2023-12-08 21:49:27 +05:30
ishaanparlikar 55235839ff Hiding rating when hide spoiler is checked in profile 2023-12-08 21:41:21 +05:30
dependabot[bot] e5536c5429 ui: bump svelte-preprocess from 5.1.0 to 5.1.1 (#247)
Bumps [svelte-preprocess](https://github.com/sveltejs/svelte-preprocess) from 5.1.0 to 5.1.1.
- [Changelog](https://github.com/sveltejs/svelte-preprocess/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte-preprocess/compare/v5.1.0...v5.1.1)

---
updated-dependencies:
- dependency-name: svelte-preprocess
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-07 21:02:53 +00:00
dependabot[bot] 02fc0ccb94 ui: bump eslint-config-prettier from 9.0.0 to 9.1.0 (#248)
Bumps [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) from 9.0.0 to 9.1.0.
- [Changelog](https://github.com/prettier/eslint-config-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-config-prettier/compare/v9.0.0...v9.1.0)

---
updated-dependencies:
- dependency-name: eslint-config-prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-07 21:00:25 +00:00
dependabot[bot] 83dcb85e5b server: bump github.com/golang-jwt/jwt/v5 from 5.1.0 to 5.2.0 in /server (#245)
Bumps [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) from 5.1.0 to 5.2.0.
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v5.1.0...v5.2.0)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-07 21:00:05 +00:00
dependabot[bot] 155cd21b40 ui: bump svelte from 4.2.7 to 4.2.8 (#246)
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 4.2.7 to 4.2.8.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/svelte@4.2.8/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@4.2.8/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-07 20:57:33 +00:00
dependabot[bot] f06da5b26e ui: bump eslint from 8.54.0 to 8.55.0 (#250)
Bumps [eslint](https://github.com/eslint/eslint) from 8.54.0 to 8.55.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.54.0...v8.55.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-07 20:48:14 +00:00
dependabot[bot] e00bfa0187 ui: bump vite from 4.5.0 to 4.5.1 (#251)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.5.0 to 4.5.1.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v4.5.1/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v4.5.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-07 20:45:08 +00:00
dependabot[bot] 4a008e4c2f ui: bump @types/papaparse from 5.3.11 to 5.3.14 (#249)
Bumps [@types/papaparse](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/papaparse) from 5.3.11 to 5.3.14.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/papaparse)

---
updated-dependencies:
- dependency-name: "@types/papaparse"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-07 20:35:37 +00:00
Mr 8c84fe29f9 Merge pull request #254 from sbondCo/fix-tooltip-overflow
Fix tooltip and nav overflow
2023-12-07 00:10:13 +00:00
IRHM 3bd03e4e97 nav: fix overflow and improve responsiveness 2023-12-07 00:08:14 +00:00
IRHM 4d65a16584 make tooltips fixed position
to stop overflowing when page resizes
2023-12-06 20:24:56 +00:00
Mr 21b4e49aa0 Merge pull request #253 from sbondCo/sort-indicators
Basic sort and filter indicators
2023-12-06 20:03:45 +00:00
IRHM 1bcc521d3a Basic sort and filter indicators 2023-12-06 20:01:49 +00:00
IRHM 9fa85e3191 v1.27.0 2023-12-03 23:49:12 +00:00
IRHM 55ff90a190 1.27.0-dev1 2023-12-03 23:38:51 +00:00
IRHM 2bf7639ecf fix user cards on dark theme 2023-12-03 23:38:06 +00:00
IRHM 2aabd94311 1.27.0-dev 2023-12-03 23:25:07 +00:00
Mr 17a61c0248 Posters - Use cached images on watched list (#243)
* upload avatar: add comment

* Poster: Use local cached image if show is on watched list
2023-12-03 15:41:12 +00:00
IRHM a17c7a4ce5 profile: update user state when bio updates 2023-12-03 15:28:45 +00:00
IRHM f31009fa5e list: fix profile data not updating 2023-12-03 15:28:45 +00:00
IRHM 559ac8945a fix name overflows not showing ellipsis 2023-12-03 15:28:45 +00:00
IRHM 1ce7f5a938 list: center user card
think it looks better like this
2023-12-03 15:28:45 +00:00
IRHM d634956d76 list: hide bio if not set and add gap to user-row 2023-12-03 15:28:45 +00:00
IRHM 3275ad6620 list: add title of full bio 2023-12-03 15:28:45 +00:00
IRHM 3ac9e94f89 list: fix bio and clamp to 2 lines
i dont care about the newlines anymore
2023-12-03 15:28:45 +00:00
IRHM 9562b3fef0 profile: fix and add notifs to post requests 2023-12-03 15:28:45 +00:00
IRHM ae95329d06 Show avatar/bio on user page, change follow btn to an icon 2023-12-03 15:28:45 +00:00
IRHM 1e81872876 move profile avatar to new UserAvatar component 2023-12-03 15:28:45 +00:00
IRHM 90eb422c9d fix user search and display proper errors 2023-12-03 15:28:45 +00:00
IRHM 3bfcb741f1 tasks: setup daily tasks interval 2023-12-03 15:28:45 +00:00
IRHM e04d72e565 images: cleanup: change debug to info logs 2023-12-03 15:28:45 +00:00
IRHM 99aa4e564a support bio 2023-12-03 15:28:45 +00:00
IRHM eac5c5a91e images: update comment 2023-12-03 15:28:45 +00:00
IRHM 82e9a4c0ea check file type is supported image 2023-12-03 15:28:45 +00:00
IRHM 4dd494dc80 profile: update user avatar when changed 2023-12-03 15:28:45 +00:00
IRHM 4422e83cd7 wip cleanup images task 2023-12-03 15:28:45 +00:00
IRHM eec6e00dac profile: remove canvas after image load
and remove z-indexes on img and canvas
2023-12-03 15:28:45 +00:00
IRHM 579736eb10 perform blurhash magic
probs wanna remove that canvas after img loads
2023-12-03 15:28:45 +00:00
IRHM 06fadeffa1 profile: show avatar 2023-12-03 15:28:45 +00:00
IRHM 97c87ecbf0 wip setting user avatar
uploading and setting working, need to display
2023-12-03 15:28:45 +00:00
Mr e55593d62b Nav: Hide buttons when searchbar is focused (#241) 2023-11-29 16:56:15 +00:00
dependabot[bot] 2a3b5c8746 server: bump github.com/gin-contrib/cors from 1.4.0 to 1.5.0 in /server (#232)
Bumps [github.com/gin-contrib/cors](https://github.com/gin-contrib/cors) from 1.4.0 to 1.5.0.
- [Release notes](https://github.com/gin-contrib/cors/releases)
- [Changelog](https://github.com/gin-contrib/cors/blob/master/.goreleaser.yaml)
- [Commits](https://github.com/gin-contrib/cors/compare/v1.4.0...v1.5.0)

---
updated-dependencies:
- dependency-name: github.com/gin-contrib/cors
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-29 14:04:04 +00:00
dependabot[bot] 52097df861 ui: bump prettier from 3.0.3 to 3.1.0 (#231)
Bumps [prettier](https://github.com/prettier/prettier) from 3.0.3 to 3.1.0.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.0.3...3.1.0)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-29 14:03:19 +00:00
dependabot[bot] 92637aef3d ui: bump axios from 1.6.1 to 1.6.2 (#230)
Bumps [axios](https://github.com/axios/axios) from 1.6.1 to 1.6.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.1...v1.6.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-29 14:02:53 +00:00
dependabot[bot] 02f537c5bf ui: bump svelte-check from 3.6.0 to 3.6.2 (#229)
Bumps [svelte-check](https://github.com/sveltejs/language-tools) from 3.6.0 to 3.6.2.
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check-3.6.0...svelte-check-3.6.2)

---
updated-dependencies:
- dependency-name: svelte-check
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-29 14:02:08 +00:00
dependabot[bot] c63ab3cddf server: bump golang.org/x/crypto from 0.15.0 to 0.16.0 in /server (#233)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.15.0 to 0.16.0.
- [Commits](https://github.com/golang/crypto/compare/v0.15.0...v0.16.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-29 14:01:33 +00:00
dependabot[bot] cf7457a671 ui: bump @typescript-eslint/parser from 6.11.0 to 6.13.1 (#240)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 6.11.0 to 6.13.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.13.1/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-29 14:00:19 +00:00
dependabot[bot] 095cc21ea4 ui: bump @typescript-eslint/eslint-plugin from 6.11.0 to 6.13.0 (#227)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 6.11.0 to 6.13.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.13.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-29 13:57:56 +00:00
IRHM 983f4efa95 v1.26.0 2023-11-28 02:53:50 +00:00
IRHM 4573d69bbf 1.26.0-dev2 2023-11-28 02:45:19 +00:00
IRHM 07cbcb4d41 fix search page padding/margin 2023-11-28 02:44:44 +00:00
IRHM 71e31df2bf 1.26.0-dev1 2023-11-28 02:27:26 +00:00
IRHM f969c591c1 nav menus: fix themeing on arrow 2023-11-28 02:26:50 +00:00
IRHM 6d1e95ff5c 1.26.0-dev 2023-11-28 02:19:02 +00:00
Mr e7a6a888b1 watched: changed movie default status to FINISHED (#239)
* watched: changed movie default status to FINISHED

when adding content to list by rating instead of giving status.

* update comment
2023-11-28 02:16:19 +00:00
Mr ab9fdddc14 Merge pull request #234 from sbondCo/following
Following Users & Fix user lists posters
2023-11-28 02:02:52 +00:00
IRHM 6160e906df poster: fix interaction-disabled for user lists 2023-11-28 01:49:18 +00:00
IRHM dafac30df1 hide following tooltip if menu open 2023-11-26 19:32:45 +00:00
IRHM e98b6a383d style user page 2023-11-26 19:30:50 +00:00
IRHM 679168d88c unfollow, responsive height sub menus 2023-11-26 19:11:11 +00:00
IRHM 8679cdf0c6 tsconfig: include env.d.ts 2023-11-26 15:06:27 +00:00
IRHM 27e2e98ca3 move sort menu and filter menu to their own components 2023-11-26 13:40:28 +00:00
IRHM 3f90b2f0ca sub menu arrows to icon 2023-11-26 13:28:38 +00:00
IRHM dfa4e81dc2 basic following sub menu 2023-11-26 02:33:14 +00:00
IRHM 3d3bfa50bb wip get followers and add new following icon
also fix compass icon (not filled) and fix tooltips
2023-11-25 21:47:39 +00:00
IRHM f4a4b7c6d7 improve search resposiveness for large screens 2023-11-25 21:21:28 +00:00
IRHM db1b1bee65 wip follow routes 2023-11-25 14:32:19 +00:00
IRHM 53aa76f009 translate errors with gorm
and instead of using strings.Contains, we can compare to gorms error now
2023-11-25 14:31:50 +00:00
IRHM 24fe7f4984 create follows table 2023-11-25 13:31:39 +00:00
IRHM 99d9d4866a Cleanup search page and userslist
by cleanup i mean make look nicer
2023-11-21 20:43:50 +00:00
IRHM 7b0594a39b v1.25.0 2023-11-20 22:39:01 +00:00
IRHM 628dfef8d1 1.25.0-dev 2023-11-20 22:28:21 +00:00
Mr a3e305478b Merge pull request #219 from sbondCo/dependabot/npm_and_yarn/svelte-4.2.7
ui: bump svelte from 4.2.3 to 4.2.7
2023-11-20 22:22:20 +00:00
dependabot[bot] 0ca133f002 ui: bump svelte from 4.2.3 to 4.2.7
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 4.2.3 to 4.2.7.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/svelte@4.2.7/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@4.2.7/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-20 22:21:30 +00:00
Mr 0586e91b31 Merge pull request #218 from sbondCo/dependabot/npm_and_yarn/svelte-check-3.6.0
ui: bump svelte-check from 3.5.2 to 3.6.0
2023-11-20 22:20:53 +00:00
Mr 3dbcc7b69f Merge pull request #220 from sbondCo/dependabot/npm_and_yarn/typescript-5.3.2
ui: bump typescript from 5.2.2 to 5.3.2
2023-11-20 22:19:33 +00:00
dependabot[bot] 784b6f2559 ui: bump typescript from 5.2.2 to 5.3.2
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.2.2 to 5.3.2.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v5.2.2...v5.3.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-20 22:18:57 +00:00
Mr d61711ace3 Merge pull request #221 from sbondCo/dependabot/npm_and_yarn/eslint-8.54.0
ui: bump eslint from 8.53.0 to 8.54.0
2023-11-20 22:18:41 +00:00
Mr c520cd6be0 Merge pull request #222 from sbondCo/dependabot/npm_and_yarn/sveltejs/kit-1.27.6
ui: bump @sveltejs/kit from 1.27.5 to 1.27.6
2023-11-20 22:18:14 +00:00
Mr 00a3b7041e Merge pull request #223 from sbondCo/tmdb-ratings
Import rating and rating date from TMDb, give activity customDate column
2023-11-20 22:17:25 +00:00
IRHM 6a69fbdab2 i forgot this is a pointer 2023-11-20 22:14:53 +00:00
IRHM deb30ebf03 import: only add IMPORTED_RATING activity if we have a customDate to use
otherwise this activity is useless and a duplicate of the normal IMPORTED activity
2023-11-20 22:13:08 +00:00
IRHM 985e5b3d39 Change imported rating activity text when there is no rating
to "added to watchlist with no rating"
2023-11-20 22:11:02 +00:00
IRHM 2b3dcd57d3 Show activity with customDate if set
also setup imported rating text - same as normal rating, but distinct so we have the ability to change it later if we want
2023-11-20 21:57:40 +00:00
dependabot[bot] 4200b03736 ui: bump @sveltejs/kit from 1.27.5 to 1.27.6
Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 1.27.5 to 1.27.6.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/master/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@1.27.6/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-20 19:57:56 +00:00
dependabot[bot] 7190a43604 ui: bump eslint from 8.53.0 to 8.54.0
Bumps [eslint](https://github.com/eslint/eslint) from 8.53.0 to 8.54.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.53.0...v8.54.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-20 19:57:43 +00:00
dependabot[bot] 7c77d9c536 ui: bump svelte-check from 3.5.2 to 3.6.0
Bumps [svelte-check](https://github.com/sveltejs/language-tools) from 3.5.2 to 3.6.0.
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check-3.5.2...svelte-check-3.6.0)

---
updated-dependencies:
- dependency-name: svelte-check
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-20 19:57:10 +00:00
IRHM bd844b17c4 Support CustomDate on activity and add import rating activity using it 2023-11-20 15:33:02 +00:00
IRHM 9bf3e6728c Support importing "Your Rating" from tmdb export 2023-11-20 14:36:50 +00:00
IRHM 78542120da v1.24.0 2023-11-19 20:55:31 +00:00
IRHM 6148773965 1.24.0-dev 2023-11-19 20:34:24 +00:00
Mr 1796bc9fb7 Merge pull request #216 from sbondCo/db-importing
TMDb Importing
2023-11-19 20:21:09 +00:00
IRHM 86c82181e1 add try again to err message 2023-11-19 20:17:19 +00:00
IRHM 51aec112b4 add error notify when no files found in dropped items
Co-Authored-By: Mathieu Moalic <21007252+mathieumoalic@users.noreply.github.com>
2023-11-19 20:14:22 +00:00
IRHM bffb7960cc import: update not supported text
Co-Authored-By: Mathieu Moalic <21007252+mathieumoalic@users.noreply.github.com>
2023-11-19 20:09:58 +00:00
IRHM 075b688605 basic tmdb import support
Co-Authored-By: Mathieu Moalic <21007252+mathieumoalic@users.noreply.github.com>
2023-11-19 19:45:46 +00:00
IRHM 6f10d221cf v1.23.0 2023-11-19 06:51:49 +00:00
IRHM 37fad5b8d3 v1.23.0 2023-11-19 06:47:53 +00:00
IRHM 8c544535c6 1.23.0-dev 2023-11-19 06:38:55 +00:00
Mr 23d407fbd6 Merge pull request #213 from sbondCo/admin-stats
Admin stats
2023-11-19 02:09:11 +00:00
IRHM 20ca9ff2be Make most watched stats take you to content on click 2023-11-19 02:05:21 +00:00
IRHM 1b8a8ba491 routes: use store somewhere else so it was worth moving in last commit
told u am smart
2023-11-19 01:50:43 +00:00
IRHM e7d93649c2 routes: move in memory store var to BaseRouter
for REUSE becuz im smart
2023-11-19 01:48:08 +00:00
IRHM 53b8b1d500 getServerStats: Add error logging even though my code has 0% chance of errorring 2023-11-19 01:37:53 +00:00
IRHM 6ae214bd98 Stats: Remove unused media query 2023-11-19 01:20:10 +00:00
IRHM 7f067c62a4 get and display server stats 2023-11-19 01:19:39 +00:00
IRHM 2e6bfc9985 move profile stats to reusable components 2023-11-18 23:16:09 +00:00
IRHM 33ea4cd3fe setup server stats route and service 2023-11-18 23:15:51 +00:00
104 changed files with 15815 additions and 4292 deletions
+5
View File
@@ -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! -->
+1 -1
View File
@@ -9,7 +9,7 @@ updates:
interval: "weekly"
commit-message:
prefix: "ui"
# Server
- package-ecosystem: "gomod"
directory: "/server"
+10
View File
@@ -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 }}
+7
View File
@@ -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 }}
+28
View File
@@ -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
+1 -1
View File
@@ -12,4 +12,4 @@
"program": "${fileDirname}"
}
]
}
}
+3 -1
View File
@@ -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
+8 -8
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
module.exports = {
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
presets: [require.resolve("@docusaurus/core/lib/babel/preset")]
};
+8
View File
@@ -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):
![Registering twitch application](./img/twitch-register-app.png)
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.
![Navigating to server settings in Watcharr](./img/watcharr-settings-dropdown.png)
2. Scroll down and click on the Twitch service.
![Twitch service config button](./img/watcharr-twitch-service.png)
3. Enter your Client ID and Client Secret (from Twitch Application page, step 6 on twitch setup steps)
![Twitch service config](./img/watcharr-twitch-config.png)
4. Click save, and start searching for your favourite games!
![Watcharr game details page](./img/watcharr-game-details.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+5 -4
View File
@@ -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",
+4817 -2950
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -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"
}
}
+1 -1
View File
@@ -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
/*
+1 -1
View File
@@ -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;
+4874 -351
View File
File diff suppressed because it is too large Load Diff
+20 -16
View File
@@ -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"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 766 KiB

After

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 948 KiB

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 866 KiB

After

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 877 KiB

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 945 KiB

After

Width:  |  Height:  |  Size: 816 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 36 KiB

+21 -15
View File
@@ -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())
+70 -8
View File
@@ -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
}
+93
View File
@@ -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
}
+140 -2
View File
@@ -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
}
+158
View File
@@ -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
}
+276
View File
@@ -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")
}
+139
View File
@@ -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, &timestamp)
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"`
}
+212
View File
@@ -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
}
+7 -5
View File
@@ -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
+12 -43
View File
@@ -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=
+201
View File
@@ -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
}
+57 -11
View File
@@ -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
}
+87 -9
View File
@@ -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
}
+258 -38
View File
@@ -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() {
+15
View File
@@ -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)
}
}
+115 -6
View File
@@ -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
}
+16 -3
View File
@@ -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)
+43 -104
View File
@@ -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
}
+2
View File
@@ -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">
+25 -4
View File
@@ -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>
+121
View File
@@ -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>
+79 -9
View File
@@ -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">
+4 -2
View File
@@ -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>
+56 -14
View File
@@ -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 {
+73 -31
View File
@@ -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>
+10 -3
View File
@@ -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>
+17 -10
View File
@@ -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>
+31 -14
View File
@@ -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;
+166 -32
View File
@@ -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">
+1
View File
@@ -47,6 +47,7 @@ export default function tooltip(node: HTMLElement, opts: ToolTipOptions) {
return {
update(opts: ToolTipOptions) {
text = opts.text;
condition = opts.condition ?? true;
},
destroy() {
+154
View File
@@ -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>
-4
View File
@@ -21,9 +21,5 @@
gap: 15px;
margin-top: auto;
padding-top: 10px;
& span {
margin-bottom: 2px;
}
}
</style>
+13 -7
View File
@@ -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);
}
+95
View File
@@ -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>
+88
View File
@@ -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>
+148
View File
@@ -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>
+54
View File
@@ -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>
+100
View File
@@ -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>
+412
View File
@@ -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>
+3 -1
View File
@@ -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%;
}
+98 -28
View File
@@ -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,
+24 -1
View File
@@ -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;
}
+20 -3
View File
@@ -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;
-13
View File
@@ -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>
+51
View File
@@ -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>
+13
View File
@@ -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>
+141 -37
View File
@@ -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)
*/
+44 -2
View File
@@ -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.
+3 -1
View File
@@ -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;
}
+68 -1
View File
@@ -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;
}
}
}
+118 -267
View File
@@ -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;
}
}
}
}
+352
View File
@@ -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>
+15
View File
@@ -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;
+200 -116
View File
@@ -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>
+66 -5
View File
@@ -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>
+13 -3
View File
@@ -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}
+199 -41
View File
@@ -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>
+172 -32
View File
@@ -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>
+75 -1
View File
@@ -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>
+13 -2
View File
@@ -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}
+27
View File
@@ -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"
+26 -4
View File
@@ -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");
}
});
}
+188 -7
View File
@@ -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
}
+1
View File
@@ -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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Some files were not shown because too many files have changed in this diff Show More