Compare commits

...

183 Commits

Author SHA1 Message Date
IRHM 20fd6decf6 v1.10.0 2023-07-22 18:31:34 +01:00
IRHM beaf935c7f v1.10.0-dev2 2023-07-22 17:59:53 +01:00
Mr 1876f53ba4 Merge pull request #57 from sbondCo/fix-nav-dds
Nav DropDowns: Close other menu on open
2023-07-18 20:49:43 +00:00
IRHM 5df24a8327 Nav DropDowns: Close other menu on open 2023-07-18 20:41:44 +01:00
Mr 2ad979a7c5 Merge pull request #55 from sbondCo/profile
Profile
2023-07-18 19:36:38 +00:00
IRHM d8e18ad854 add margin to profile 2023-07-18 20:30:31 +01:00
IRHM 8450880cc9 profile: only count as watched if status is finished 2023-07-18 20:27:20 +01:00
IRHM 937e83b17b Create profile endpoint and add basic stats to page 2023-07-17 23:56:35 +01:00
IRHM 624d0eef23 Check episoderuntime len 2023-07-15 23:57:29 +01:00
IRHM 27315bb890 Add runtime, number of eps and seasons to content db 2023-07-15 23:45:22 +01:00
IRHM 15dfcfd477 start profile page 2023-07-14 23:27:29 +01:00
IRHM 2f2ed4ee4e Run debian 12 in runner dockerfile step 2023-07-13 23:54:24 +01:00
IRHM 971de1b458 Readme note on watcharr-ui package deprication 2023-07-13 23:02:46 +01:00
IRHM 3cedd75abd 1.10.0-dev 2023-07-13 22:43:20 +01:00
Mr 3486fb1521 Create CONTRIBUTING.md 2023-07-13 21:37:47 +00:00
Mr f26d7bb846 Merge pull request #53 from sbondCo/activity
Content Activity
2023-07-13 21:20:30 +00:00
IRHM 8e8fe57a6e Create .env.example
must have forgotten
2023-07-13 22:12:56 +01:00
IRHM 63d4d276fe Fix differences in returned and saved status/rating when readding content 2023-07-12 07:22:38 +01:00
IRHM 6fa49603a9 Only show activity is show is in watched list 2023-07-12 06:59:21 +01:00
IRHM b1e149a0ed Preload activities when adding show back
incase user has previous activities we should load
2023-07-12 06:56:35 +01:00
IRHM 7ac83d4744 removed from watched list msg 2023-07-12 06:43:43 +01:00
IRHM db26be7033 Update watched.go 2023-07-12 06:42:45 +01:00
IRHM 213264ad23 removed watched activity and soft deleting watched records 2023-07-12 06:40:33 +01:00
IRHM 751674e60f Change activity group name font 2023-07-12 05:33:20 +01:00
IRHM 28baca9e2a Group activities by month year 2023-07-12 05:30:24 +01:00
IRHM 70ecdf82d7 Make activity scrollable 2023-07-12 04:36:26 +01:00
IRHM 5b572edd34 Update activity when updating content 2023-07-12 04:34:36 +01:00
IRHM 8faa43b919 Add activity to watched item response when adding 2023-07-10 23:57:49 +01:00
IRHM 27f362f0a1 update error log in watched 2023-07-10 20:52:22 +01:00
IRHM 290654b17a show activity in movie page 2023-07-10 20:51:10 +01:00
IRHM 54178bd8e6 Save and show rating/status at add_watched time 2023-07-10 20:51:03 +01:00
IRHM 444e884d5f activity working
just needs to get live updates
2023-07-10 20:34:42 +01:00
IRHM 944492375b wip activity routes 2023-07-05 23:57:49 +01:00
IRHM 3fbef9171d style test activity component 2023-06-21 21:00:39 +01:00
Mr d2a08367e2 Merge pull request #52 from sbondCo/tv-eps
TV Seasons/Episodes
2023-06-21 20:33:14 +01:00
IRHM 7c3b8cd5ce Check for still_path before using in img tag 2023-06-18 21:31:06 +01:00
IRHM a1f9318ef3 Fix failed image styling 2023-06-18 21:26:20 +01:00
IRHM 7785a07bb0 Make seasons list responsive 2023-06-18 21:05:03 +01:00
IRHM c09f4a1be5 seasons scroll 2023-06-18 20:44:19 +01:00
IRHM 8abb29ac45 Show tv seasons/episodes 2023-06-18 12:38:38 +01:00
Mr 7313910483 Merge pull request #51 from sbondCo/filters
Watched Page Filters
2023-06-09 20:28:46 +01:00
IRHM 9625378eb5 Fix review section in content pages 2023-06-09 20:25:21 +01:00
IRHM ff0eb750bb Finalize filters menu 2023-06-06 12:21:30 +01:00
IRHM a8a3960705 store more info in content table 2023-06-05 22:02:12 +01:00
IRHM 0ecf8ef145 Create basic filter menu 2023-06-01 23:25:03 +01:00
Mr 707a748bb2 Update README.md 2023-06-01 19:28:26 +00:00
IRHM eec1d6a4c3 Update env var comment in README 2023-06-01 03:07:03 +01:00
IRHM 8e43ee21a7 v1.9.2 2023-06-01 02:58:05 +01:00
IRHM 80b808c39e Go back to adapter-node and run it in backend
proxies requests to it
2023-06-01 02:44:41 +01:00
IRHM eb27892e9a v1.9.1 2023-05-31 23:51:47 +01:00
Mr 889c7e32d2 Merge pull request #50 from sbondCo/docker-img-rework
Simplify Docker Images Into One
2023-05-31 22:46:48 +00:00
IRHM ca1a91e992 Update container-release.yml 2023-05-31 23:36:04 +01:00
IRHM 635eb7609c Update README setup steps 2023-05-31 23:34:31 +01:00
IRHM 3fefdfdfb8 Remove Caddyfile and update docker-compose example/test files 2023-05-31 23:33:15 +01:00
IRHM 43ffa6d694 Update test dev action 2023-05-31 23:12:39 +01:00
IRHM 23164858f8 Combine separated docker images into one
Server now also serves the frontend in production
2023-05-31 22:55:57 +01:00
IRHM 2134e78e47 1.10.0-dev 2023-05-31 00:53:06 +01:00
IRHM 32370e2ec2 1.9.0 2023-05-31 00:28:52 +01:00
IRHM 8c23dd1b95 Upgrade server dependencies 2023-05-31 00:26:56 +01:00
IRHM 9a1df59fc5 Update Poster.svelte 2023-05-31 00:18:31 +01:00
Mr 8ec1ea2c4a Merge pull request #49 from sbondCo/poster-glitch
Poster glitch
2023-05-30 23:11:33 +00:00
IRHM d393072b4a Fix doubly scaled posters
Clicking on a hovered poster can cause the focus event to fire, which would calculate the transform-origin property again, but doubly scaled
2023-05-31 00:07:28 +01:00
IRHM 074f3a0b19 PersonPoster: Role is not a required prop 2023-05-30 23:59:04 +01:00
Mr e896be4429 Merge pull request #48 from sbondCo/person-posters
Move calculateTransformOrigin to helpers and use it in person poster too
2023-05-30 22:54:54 +00:00
IRHM 0665666306 Move calculateTransformOrigin to helpers and use it in person poster too 2023-05-30 23:53:49 +01:00
Mr 751a06513b Merge pull request #47 from sbondCo/posters-out-of-view
Posters out of view on hover
2023-05-30 22:36:25 +00:00
IRHM 342c61e9bd Allow for more than one origin
Just so items at bottom can also be directed left/right
2023-05-30 23:34:28 +01:00
IRHM dd5889ff24 Fix posters going out of view on hover
Scale could cause poster to go out of view
2023-05-30 22:35:58 +01:00
Mr 0b86e09f00 Merge pull request #43 from sbondCo/cast
Display Cast
2023-05-29 21:18:59 +00:00
IRHM b62beee787 Space between cast and add missing styles to tv page 2023-05-29 22:17:16 +01:00
IRHM 0207fe82c9 Show cast and crew 2023-05-29 22:12:11 +01:00
Mr 75bb481cd4 Merge pull request #39 from sbondCo/move-files
Move api.ts and helpers.ts to util folder
2023-05-18 18:14:24 +00:00
IRHM 6c7c9d8294 Move api.ts and helpers.ts to util folder 2023-05-18 19:09:26 +01:00
Mr a145c95d90 Merge pull request #38 from sbondCo/show-username
Show username at top of face menu
2023-05-18 17:56:42 +00:00
IRHM e426d9be3a Show username at top of face menu
also use form var instead of running .get again on form data in login
2023-05-18 18:55:04 +01:00
Mr 8d51e48b37 Merge pull request #36 from sbondCo/error-popups
Create notification popups
2023-05-17 17:19:33 +00:00
Mr 150f377920 Merge branch 'dev' into error-popups 2023-05-17 17:17:45 +00:00
IRHM 4432fc7e13 Remove test focuses 2023-05-17 17:08:45 +00:00
IRHM ef78a84890 Increase debounce time on mobile
Just chrome on mobile has a keyboard jiggle (firefox the champ is good) after search, it seems like not so much an issue with the increased time to type.
2023-05-17 17:08:45 +00:00
IRHM d6bff05bfb Set dev version 1.9.0 2023-05-17 18:05:55 +01:00
IRHM 66eadfceac v1.8.0 2023-05-17 17:59:14 +01:00
IRHM fad27de764 Create notification popups 2023-05-17 17:56:51 +01:00
IRHM bd24f3507e Update current version to 1.8.0-dev 2023-05-16 23:54:13 +01:00
IRHM 8dbf1e1f2c Posters: Break word if name too long 2023-05-16 22:49:55 +00:00
IRHM dbffc9f438 PersonPoster: Make text more readable 2023-05-16 22:49:55 +00:00
IRHM e146247113 Remove unused css in PersonPoster 2023-05-16 22:49:55 +00:00
IRHM 52653c866e Make addClassToParent a helper method 2023-05-16 22:49:55 +00:00
IRHM f7e7c03f19 Create PersonPoster component
So we can seperate out the person logic, so it doesn't become a confusing mess. Always shows name.
2023-05-16 22:49:55 +00:00
IRHM 8d8b006bb9 Remove x padding on movies in person page
So mobile shows as 2 cols
2023-05-16 21:57:34 +00:00
IRHM 6690563645 Fix env.d.ts name 2023-05-16 21:24:15 +00:00
IRHM 6401422d8b Log version to console 2023-05-16 21:24:15 +00:00
IRHM d17835b246 Add version to face menu 2023-05-16 21:24:15 +00:00
Mr e024ba7767 Add 'may be bugs' msg to dev demo link 2023-05-15 23:11:35 +00:00
IRHM 6f26e1c534 Only show available auth providers 2023-05-15 23:09:25 +00:00
Mr ccc0bcefc6 Merge pull request #28 from sbondCo/people
Support Showing People In Search
2023-05-15 23:06:08 +00:00
IRHM 0dacde99e5 Sort person credits by amt of votes
Votes avg not that great since a show could be voted for by one person, this seems like a better idea... i think
2023-05-15 23:02:47 +01:00
IRHM 8004ae3d42 Fix poster title and show all person credits
It works fine with all of them for now.
2023-05-11 23:31:42 +01:00
IRHM 0d25f48635 Show people and create person page
people posters have a lil P on them
2023-05-11 23:14:39 +01:00
IRHM ec966f5708 Show person image on search 2023-05-10 22:11:16 +01:00
Mr 9719a05993 Fix images and centering in readme 2023-05-09 18:53:58 +00:00
IRHM ae4ce82f8c Add screenshots to readme 2023-05-09 19:46:50 +01:00
IRHM ab138db8b2 Add setup instructions to readme 2023-05-09 19:30:22 +01:00
IRHM 5c17a1f124 v1.7.0 2023-05-09 01:52:22 +01:00
IRHM a2496a0a2f Fix small nav logo width 2023-05-08 21:23:29 +00:00
IRHM 2d97560a91 Fix being able to delete other users watched records 2023-05-08 21:07:03 +00:00
IRHM 771b857f17 Fix adding watched item if content not being created at same time 2023-05-08 21:07:03 +00:00
IRHM 86799daa91 Support removing watched items
currently from poster status menu
2023-05-08 21:07:03 +00:00
IRHM 6b9da3be6b Use touchend instead of touchcancel event to hide tooltip 2023-05-08 14:36:20 +00:00
IRHM a564e91dfb Make login h2 normal weight 2023-05-08 14:36:20 +00:00
IRHM 9cd8b38143 Fix nav logo width increasing on hover 2023-05-08 14:36:20 +00:00
IRHM 5b31df77a5 Fix mob seeing black background at bottom of page 2023-05-08 14:36:20 +00:00
IRHM 4b6bee9c37 Fix tooltips position whilst page is scrolled 2023-05-08 14:28:57 +00:00
IRHM 91e1e878f9 Use local font files and new font for nav bar 2023-05-07 23:44:24 +00:00
IRHM deeec0cb92 chrome: Fix nav face and poster statuses
nav logo looks weird still on chrome, may need to replace with an svg
2023-05-07 23:44:24 +00:00
IRHM a02f7e8999 Show extra details in PageError if available
changed error type to any
2023-05-07 22:32:37 +00:00
IRHM 9812b47be3 tv and movie page loaders 2023-05-07 22:32:37 +00:00
IRHM d6ac2df49d Make page loader, poster loader and page errors
- Spinner component for page loading
- Poster image loader
- PageError component - for displaying errors when fetching required page data
2023-05-07 22:32:37 +00:00
IRHM 0179c75051 Make tooltips work better on mobile 2023-04-26 20:14:50 +00:00
IRHM d4674c6c1a Hide tooltip on click of 'node' 2023-04-26 20:14:50 +00:00
IRHM d540779c00 Add margin to login page
so its not hitting the sides of screen on mobile
2023-04-26 20:14:50 +00:00
IRHM 0632f729fe Create reusable tooltip action - applied to watched statuses 2023-04-26 20:14:50 +00:00
IRHM 3e7f0e3609 v1.6.0 2023-04-25 19:37:45 +01:00
IRHM 71d0b465ef Fix tv page and places using wrong content id 2023-04-25 18:32:41 +00:00
IRHM 7683d98dbe Poster: Remove anchor and make keyboard accessible again 2023-04-25 18:13:47 +00:00
IRHM 93828e5469 Show small logo on mobile nav 2023-04-25 18:13:47 +00:00
IRHM 73ac7245bb Fix poster link on mobile and mobile transition performance 2023-04-25 18:13:47 +00:00
IRHM 513f1d5094 Reduce x margin on PosterList 2023-04-25 18:13:47 +00:00
IRHM fe75448f19 Move tmdbId to its own field and have autoIncrement id for content
tmdb ids aren't unique!!!!!!
2023-04-25 18:13:47 +00:00
IRHM 81e19c2e75 v1.5.0 2023-04-21 00:50:00 +01:00
IRHM 69fcbec1e2 watched: Fix poster using localhost for img
Move all baseURLS to one variable
2023-04-20 23:49:05 +00:00
IRHM 9779bb4c69 Update container-dev.yml 2023-04-20 23:49:05 +00:00
IRHM 6af799f71d Update container-dev.yml 2023-04-20 23:49:05 +00:00
IRHM 41740c993d Update container-dev.yml 2023-04-20 23:49:05 +00:00
IRHM 96e4491a52 Add sha as tag for dev workflow release 2023-04-20 23:49:05 +00:00
IRHM 8e318758cd Fix: Dont handleSearch if key pressed is not a letter (Tab, caps, meta, etc)) 2023-04-20 23:49:05 +00:00
IRHM 25e215296e Poster: Close ratings menu if a rating is clicked 2023-04-20 23:49:05 +00:00
IRHM ac00d1df6c Poster: Give active posters pointer cursor 2023-04-20 23:49:05 +00:00
IRHM a316197413 Poster: Link prop for the url to go to when poster clicked
also created an a tag around the title for la keyboard users ⌨️
2023-04-20 23:49:05 +00:00
IRHM ff903570f6 Poster: Blur buttons after mouse leave - fixes poster staying open after mouse leave 2023-04-20 23:49:05 +00:00
IRHM ae6ec13c41 Poster: Simplify statuses with #each
- Move Icon type to types.ts
- Remove iconFromStatus and create an object with each status to icon
2023-04-20 23:49:05 +00:00
IRHM 7f3ec70c92 🤫 Poster: bring back la wrapper 2023-04-20 23:49:05 +00:00
IRHM 3aef3fc841 Fix watched page poster update call 2023-04-20 23:49:05 +00:00
IRHM fe4ac10338 Remove wrapper div on posters and make btns tabbable 2023-04-20 23:49:05 +00:00
IRHM e5ed41185f Remove ratingBtnClicked method, just do it inline 2023-04-20 23:49:05 +00:00
IRHM f04b054969 Create tv details page 2023-04-20 23:49:05 +00:00
IRHM e5a070e0cb + icon when no status on poster 2023-04-20 23:49:05 +00:00
IRHM 3bac5a7cee Status buttons on poster 2023-04-20 23:49:05 +00:00
IRHM 60834a9a04 New ui for rating a show via poster 2023-04-20 23:49:05 +00:00
IRHM f01f332a13 Make btn svg width auto 2023-04-20 23:49:05 +00:00
IRHM 1e6b8bf9cd WIP: New poster buttons 2023-04-20 23:49:05 +00:00
IRHM 5898436ef3 Effect of nav elements hover 2023-04-20 23:49:05 +00:00
IRHM 854821cd26 Fix rating/status issues and simplify it
Also removed Rating type, just using number now.
2023-04-20 23:49:05 +00:00
IRHM 11b8e0498b Make ratings font size bigger 2023-04-20 23:49:05 +00:00
IRHM a84078e119 Details page - Make poster bigger, center it and make responsive again 2023-04-20 23:49:05 +00:00
IRHM d4a29971cb Stop preload on watched list link 2023-04-20 23:49:05 +00:00
IRHM 3ce4bc0552 Use new components in movie detail page 2023-04-20 23:49:05 +00:00
IRHM bb0c813274 Add pause, play and thumb down icons 2023-04-20 23:49:05 +00:00
IRHM 62c466320d Fix Rating component handling of null/undef rating var 2023-04-20 23:49:05 +00:00
IRHM 247c1f7763 🐥 Create Rating and Status components 2023-04-20 23:49:05 +00:00
IRHM 84a7930736 Server: Return null instead of default values for status/rating
Also added HOLD/DROPPED vars to WatchedStatus type and increased max rating to 10
2023-04-20 23:49:05 +00:00
IRHM e6b5b0d4a0 UI: Make nav and movie details page responsive 2023-04-20 23:49:05 +00:00
IRHM c8100a8bea Update svr dockerfile to install ca-certificates
Updated caddyfile to listen on all interfaces
WIP: Added stars to movie detail page
2023-04-20 23:49:05 +00:00
IRHM 4a20ada589 Basic movie details page 2023-04-20 23:49:05 +00:00
Mr 07c05f5aca Add dev demo to readme 2023-04-20 23:23:16 +00:00
IRHM 19c40c15ff Create dev workflow 2023-04-20 22:14:10 +01:00
IRHM 6c4c889a6d UI: Pass undef for poster if not set 2023-04-14 22:04:57 +00:00
IRHM e4fe770666 UI Poster: Remove object-fit 2023-04-14 22:04:57 +00:00
IRHM 372a4aad45 UI Poster: Move object-fit to img without hover 2023-04-14 22:04:57 +00:00
IRHM 9a970df9e6 UI: Show content details without hover, if poster is undef 2023-04-14 22:04:57 +00:00
IRHM 55de77d59b Set default value for usertype to 0 2023-04-14 22:04:57 +00:00
IRHM e8db43efd4 v1.4.0 2023-04-14 17:07:50 +01:00
Mr f33b4e674d Merge pull request #1 from sbondCo/jf-auth
Jellyfin Auth
2023-04-14 16:06:57 +00:00
IRHM 2dfbd99825 UI: Don't show 'Login Again' error over others 2023-04-14 16:58:41 +01:00
IRHM a4f238adcc UserType not null 2023-04-14 16:52:49 +01:00
IRHM 7168e6222f Fix User Username to UserType unique index 2023-04-14 13:26:19 +01:00
IRHM c50851e0f4 Show watcharr icon on watcharr login btn 2023-04-14 13:20:00 +01:00
IRHM bd91cdf248 Clear watchedList store on logout
To avoid seeing last logged in users' list, if new logged in user has no list or req fails
2023-04-14 13:15:42 +01:00
IRHM ee903cb9bd UI: Support jellyfin login 2023-04-14 13:12:32 +01:00
IRHM 160deb165f Panic if db migration fails 2023-04-14 00:58:59 +01:00
IRHM c7b126cfc8 Jellyfin auth support 2023-04-14 00:58:45 +01:00
IRHM 82390f388f Remove token on 401 response and go to / if token exists on login page 2023-04-13 12:39:52 +01:00
IRHM 9e66b2ba29 Separate login axios to its own instance noAuthAxios
The interceptors were interfering with each other
2023-04-13 12:39:12 +01:00
IRHM 63b5d48bf8 Show please login again when again parameter present on login page 2023-04-13 12:16:45 +01:00
66 changed files with 4485 additions and 572 deletions
+35
View File
@@ -0,0 +1,35 @@
name: Development Watcharr Container Release
on:
workflow_dispatch:
env:
image: ghcr.io/sbondco/watcharr
jobs:
release-dev-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout Repo
uses: actions/checkout@v3
- name: Log in to the Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ env.image }}:dev
labels: ${{ env.image }}:${{ github.sha }}
+5 -12
View File
@@ -6,6 +6,9 @@ on:
tags:
- v*
env:
image: ghcr.io/sbondco/watcharr
jobs:
release-images:
runs-on: ubuntu-latest
@@ -14,16 +17,6 @@ jobs:
contents: read
packages: write
strategy:
fail-fast: true
# Run for each dockerfile (one for server and one for ui images)
matrix:
include:
- dockerfile: ./Dockerfile.svr
image: ghcr.io/sbondco/watcharr
- dockerfile: ./Dockerfile.ui
image: ghcr.io/sbondco/watcharr-ui
steps:
- name: Checkout Repo
uses: actions/checkout@v3
@@ -39,13 +32,13 @@ jobs:
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ matrix.image }}
images: ${{ env.image }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
file: ${{ matrix.dockerfile }}
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+3
View File
@@ -37,3 +37,6 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Docker dev volume
container_data/
+5
View File
@@ -0,0 +1,5 @@
# Contributing
Feel free to contribute in any way: bug reports, ideas, etc - everything is welcome!
For big PRs (lots of changes/big change in the way something works), if you'd like, it can be discussed first in an issue (dont want to waste anyones time!).
-9
View File
@@ -1,9 +0,0 @@
localhost:80 {
# Reverse proxy for frontend
reverse_proxy 172.17.0.1:3000
# Route requests to /api/ to backend
handle_path /api/* {
reverse_proxy 172.17.0.1:3080
}
}
+41
View File
@@ -0,0 +1,41 @@
# Backend
FROM golang:1.20 AS server
WORKDIR /server
COPY server/*.go server/go.* ./
RUN go mod download && GOOS=linux go build -o ./watcharr
# Frontend
FROM node:19 AS ui
WORKDIR /app
COPY package*.json vite.config.ts svelte.config.js tsconfig.json ./
COPY ./src ./src
COPY ./static ./static
RUN npm install && npm run build
# Production
FROM debian:12.0 AS runner
RUN apt-get update && apt-get install ca-certificates -y
ENV NODE_VERSION=18.13.0
RUN apt install -y curl
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash
ENV NVM_DIR=/root/.nvm
RUN . "$NVM_DIR/nvm.sh" && nvm install ${NODE_VERSION}
RUN . "$NVM_DIR/nvm.sh" && nvm use v${NODE_VERSION}
RUN . "$NVM_DIR/nvm.sh" && nvm alias default v${NODE_VERSION}
ENV PATH="/root/.nvm/versions/node/v${NODE_VERSION}/bin/:${PATH}"
RUN node --version
COPY --from=server /server/watcharr /
COPY --from=ui /app/build /ui
COPY --from=ui /app/package.json /app/package-lock.json /ui
RUN cd /ui && npm ci
EXPOSE 3080
CMD ["/watcharr"]
-16
View File
@@ -1,16 +0,0 @@
# Backend
FROM golang:1.20 AS server
WORKDIR /server
COPY server/*.go server/go.* ./
RUN go mod download && GOOS=linux go build -o ./watcharr
# Production
FROM debian:11.6 AS runner
COPY --from=server /server/watcharr /
EXPOSE 3080
CMD ["/watcharr"]
-20
View File
@@ -1,20 +0,0 @@
# Frontend
FROM node:19 AS ui
WORKDIR /app
COPY package*.json vite.config.ts svelte.config.js tsconfig.json ./
COPY ./src ./src
COPY ./static ./static
RUN npm install && npm run build
# Production
FROM node:19 AS runner
WORKDIR /app
COPY --from=ui /app/package*.json /app/build ./
RUN npm ci --only=production
EXPOSE 3000
CMD ["node", "index.js"]
+74 -6
View File
@@ -1,8 +1,76 @@
<center>
<h1>Watcharr</h1>
<img src="./static/logo-col.png" alt="logo" />
</center>
<h1 align="center">Watcharr</h1>
<p align="center"><img src="./static/logo-col.png" alt="logo" width="250" /></p>
I hope to become the place you store your watched content for later rememberance, but sadly I am still being built. I aspire to be easily self hosted.
<p align="center">
<a href="https://github.com/sbondCo/Watcharr/pkgs/container/watcharr"><img src="https://img.shields.io/github/v/release/sbondCo/Watcharr?label=version&style=for-the-badge" /></a>
<a href="https://watcharr.lab.sbond.co"><img src="https://img.shields.io/website?label=DEMO&style=for-the-badge&url=https%3A%2F%2Fwatcharr.lab.sbond.co%2F" /></a>
<a href="https://github.com/sbondCo/Watcharr/issues"><img src="https://img.shields.io/github/issues-raw/sbondCo/Watcharr?label=ISSUES&style=for-the-badge" /></a>
<a href="/LICENSE"><img src="https://img.shields.io/github/license/sbondCo/Watcharr?style=for-the-badge" /></a>
</p>
I'm being build with Go and Svelte(Kit).
I'm your new easily self-hosted content watched list. The place you store your watched (or watching, planned, etc) movies and tv shows, rate them and track their status.
I am built with Go and Svelte(Kit).
Feel free to abuse this demo instance (nicely), which runs on the latest `dev` build (there may be bugs, as new features are tested on here too): [https://watcharr.lab.sbond.co/](https://watcharr.lab.sbond.co/)
[Track Progress Until Next Version](https://github.com/orgs/sbondCo/projects/9/views/3)
### Contents
- [Screenshots](#screenshots)
- [Setup](#set-up)
# Screenshots
<h3 align="center">Watched List</h3>
<p align="center">
<img src="./screenshot/homepage.png" alt="Watched List" />
| Watched Show Hover | Watched Show Status Change | Show Details |
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| <img src="./screenshot/homepage-poster-hover.png" alt="Watched List" /> | <img src="./screenshot/homepage-poster-change-status.png" alt="Changing Show Status" /> | <img src="./screenshot/show-details-page.png" alt="Content Details Page" /> |
</p>
# Set Up
Currently only a [docker image](https://github.com/sbondCo/Watcharr/pkgs/container/watcharr) is provided (**v1.9.1 and newer no longer require the `watcharr-ui` package, it is bundled inside the main `watcharr` package**).
Here is a simple setup you can use yourself, using docker compose:
**docker-compose.yml**
```
version: "3"
services:
watcharr:
# The :latest tag is used for simplicity, it is recommended
# to use an actual version, then when updating check the releases for changelogs.
image: ghcr.io/sbondco/watcharr:latest
container_name: watcharr
ports:
- 3080:3080
volumes:
# .env file used to configure watcharr
- .env:/.env
# Contains all of watcharr data (database & cache)
- ./data:/data
```
**.env**
```
# Used to sign JWT tokens. Make sure to make
# it strong, just like a very long, complicated password.
JWT_SECRET=MAKE_ME_RANDOM_AND_LONG
# Optional: Point to your Jellyfin install
# to enable it as an auth provider.
JELLYFIN_HOST=https://my.jellyfin.example
# Optional: When not set we assume production, should only
# be set to DEV when developing the app.
MODE=prod
```
+1 -11
View File
@@ -5,18 +5,8 @@ services:
watcharr:
build:
context: .
dockerfile: Dockerfile.ui
dockerfile: Dockerfile
container_name: watcharr
ports:
- 3000:3000
depends_on:
- watcharr-server
watcharr-server:
build:
context: .
dockerfile: Dockerfile.svr
container_name: watcharr-server
ports:
- 3080:3080
volumes:
-18
View File
@@ -1,7 +1,6 @@
version: "3"
services:
# Watcharr API
watcharr:
image: ghcr.io/sbondco/watcharr:latest
container_name: watcharr
@@ -10,20 +9,3 @@ services:
volumes:
- ./server/.env:/.env
- ./container_data:/data
# Watcharr Frontend
watcharr-ui:
image: ghcr.io/sbondco/watcharr-ui:latest
container_name: watcharr-ui
ports:
- 3000:3000
depends_on:
- watcharr
caddy:
image: caddy:2.6
container_name: watcharr-caddy
ports:
- "8080:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
Vendored
+1
View File
@@ -0,0 +1 @@
declare const __WATCHARR_VERSION__: string;
+15 -15
View File
@@ -1,17 +1,17 @@
{
"name": "watcharr",
"version": "1.3.0",
"version": "1.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "watcharr",
"version": "1.3.0",
"version": "1.10.0",
"dependencies": {
"axios": "^1.3.4"
},
"devDependencies": {
"@sveltejs/adapter-node": "^1.2.3",
"@sveltejs/adapter-node": "^1.2.4",
"@sveltejs/kit": "^1.15.2",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
@@ -535,9 +535,9 @@
"dev": true
},
"node_modules/@rollup/plugin-commonjs": {
"version": "24.0.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-24.0.1.tgz",
"integrity": "sha512-15LsiWRZk4eOGqvrJyu3z3DaBu5BhXIMeWnijSRvd8irrrg9SHpQ1pH+BUK4H6Z9wL9yOxZJMTLU+Au86XHxow==",
"version": "24.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-24.1.0.tgz",
"integrity": "sha512-eSL45hjhCWI0jCCXcNtLVqM5N1JlBGvlFfY0m6oOYnLCJ6N0qEXoZql4sY2MOUArzhH4SA/qBpTxvvZp2Sc+DQ==",
"dev": true,
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
@@ -632,9 +632,9 @@
}
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "15.0.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.0.2.tgz",
"integrity": "sha512-Y35fRGUjC3FaurG722uhUuG8YHOJRJQbI6/CkbRkdPotSpDj9NtIN85z1zrcyDcCQIW4qp5mgG72U+gJ0TAFEg==",
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.1.0.tgz",
"integrity": "sha512-xeZHCgsiZ9pzYVgAo9580eCGqwh/XCEUM9q6iQfGNocjgkufHAqC3exA+45URvhiYV8sBF9RlBai650eNs7AsA==",
"dev": true,
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
@@ -679,9 +679,9 @@
}
},
"node_modules/@sveltejs/adapter-node": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-1.2.3.tgz",
"integrity": "sha512-Fv6NyVpVWYA63KRaV6dDjcU8ytcWFiUr0siJOoHl+oWy5WHNEuRiJOUdiZzYbZo8MmvFaCoxHkTgPrVQhpqaRA==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-1.2.4.tgz",
"integrity": "sha512-TNnhS+OKRZ9RKnC+ho5mlE2FJquI61i0v7yOXxBUSU3LAoYH2kwVVL8P8ecjefmZ8BOfM1V54pBnDODBU5CEaA==",
"dev": true,
"dependencies": {
"@rollup/plugin-commonjs": "^24.0.0",
@@ -765,9 +765,9 @@
"dev": true
},
"node_modules/@types/estree": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz",
"integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz",
"integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==",
"dev": true
},
"node_modules/@types/json-schema": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "watcharr",
"version": "1.3.0",
"version": "1.10.0",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -12,7 +12,7 @@
"format": "prettier --plugin-search-dir . --write ."
},
"devDependencies": {
"@sveltejs/adapter-node": "^1.2.3",
"@sveltejs/adapter-node": "^1.2.4",
"@sveltejs/kit": "^1.15.2",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

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