├── .gitignore
├── Dockerfile
├── Jellyfin Rewind Banner.png
├── fonts
├── Quicksand.zip
├── Quicksand
│ ├── OFL.txt
│ ├── Quicksand-VariableFont_wght.ttf
│ ├── Quicksand_Bold.otf
│ ├── README.txt
│ └── static
│ │ ├── Quicksand-Bold.ttf
│ │ ├── Quicksand-Light.ttf
│ │ ├── Quicksand-Medium.ttf
│ │ ├── Quicksand-Regular.ttf
│ │ └── Quicksand-SemiBold.ttf
└── quicksand-bold.zip
├── index.html
├── javascript.svg
├── main.js
├── package-lock.json
├── package.json
├── postcss.config.cjs
├── public
├── config.json
├── media
│ ├── AlbumPlaceholder-dark.png
│ ├── AlbumPlaceholder.png
│ ├── ArtistPlaceholder-dark.png
│ ├── ArtistPlaceholder.png
│ ├── TrackPlaceholder-dark.png
│ ├── TrackPlaceholder.png
│ ├── banner-dark.png
│ ├── banner-dark.svg
│ ├── banner-light.png
│ ├── banner-light.svg
│ ├── jellyfin-banner-dark.svg
│ ├── jellyfin-banner-light.svg
│ ├── jellyfin-icon-transparent.svg
│ ├── jellyfin-rewind-icon.png
│ └── jellyfin-rewind-icon.svg
└── vite.svg
├── readme.md
├── requests.txt
├── src
├── aggregate.js
├── auth.js
├── delta.js
├── features.js
├── jelly-helper.js
├── offline-import.js
├── onboarding.js
├── rewind.js
├── setup.js
└── types.js
├── style.css
├── tailwind.config.cjs
└── vite.config.js
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | .env*
27 | reddit-post.md
28 | dist-self-host/
29 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM httpd:2.4-alpine
2 |
3 |
4 | COPY ./dist-self-host /usr/local/apache2/htdocs/
5 |
--------------------------------------------------------------------------------
/Jellyfin Rewind Banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/Jellyfin Rewind Banner.png
--------------------------------------------------------------------------------
/fonts/Quicksand.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/fonts/Quicksand.zip
--------------------------------------------------------------------------------
/fonts/Quicksand/OFL.txt:
--------------------------------------------------------------------------------
1 | Copyright 2011 The Quicksand Project Authors (https://github.com/andrew-paglinawan/QuicksandFamily), with Reserved Font Name “Quicksand”.
2 |
3 | This Font Software is licensed under the SIL Open Font License, Version 1.1.
4 | This license is copied below, and is also available with a FAQ at:
5 | http://scripts.sil.org/OFL
6 |
7 |
8 | -----------------------------------------------------------
9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10 | -----------------------------------------------------------
11 |
12 | PREAMBLE
13 | The goals of the Open Font License (OFL) are to stimulate worldwide
14 | development of collaborative font projects, to support the font creation
15 | efforts of academic and linguistic communities, and to provide a free and
16 | open framework in which fonts may be shared and improved in partnership
17 | with others.
18 |
19 | The OFL allows the licensed fonts to be used, studied, modified and
20 | redistributed freely as long as they are not sold by themselves. The
21 | fonts, including any derivative works, can be bundled, embedded,
22 | redistributed and/or sold with any software provided that any reserved
23 | names are not used by derivative works. The fonts and derivatives,
24 | however, cannot be released under any other type of license. The
25 | requirement for fonts to remain under this license does not apply
26 | to any document created using the fonts or their derivatives.
27 |
28 | DEFINITIONS
29 | "Font Software" refers to the set of files released by the Copyright
30 | Holder(s) under this license and clearly marked as such. This may
31 | include source files, build scripts and documentation.
32 |
33 | "Reserved Font Name" refers to any names specified as such after the
34 | copyright statement(s).
35 |
36 | "Original Version" refers to the collection of Font Software components as
37 | distributed by the Copyright Holder(s).
38 |
39 | "Modified Version" refers to any derivative made by adding to, deleting,
40 | or substituting -- in part or in whole -- any of the components of the
41 | Original Version, by changing formats or by porting the Font Software to a
42 | new environment.
43 |
44 | "Author" refers to any designer, engineer, programmer, technical
45 | writer or other person who contributed to the Font Software.
46 |
47 | PERMISSION & CONDITIONS
48 | Permission is hereby granted, free of charge, to any person obtaining
49 | a copy of the Font Software, to use, study, copy, merge, embed, modify,
50 | redistribute, and sell modified and unmodified copies of the Font
51 | Software, subject to the following conditions:
52 |
53 | 1) Neither the Font Software nor any of its individual components,
54 | in Original or Modified Versions, may be sold by itself.
55 |
56 | 2) Original or Modified Versions of the Font Software may be bundled,
57 | redistributed and/or sold with any software, provided that each copy
58 | contains the above copyright notice and this license. These can be
59 | included either as stand-alone text files, human-readable headers or
60 | in the appropriate machine-readable metadata fields within text or
61 | binary files as long as those fields can be easily viewed by the user.
62 |
63 | 3) No Modified Version of the Font Software may use the Reserved Font
64 | Name(s) unless explicit written permission is granted by the corresponding
65 | Copyright Holder. This restriction only applies to the primary font name as
66 | presented to the users.
67 |
68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69 | Software shall not be used to promote, endorse or advertise any
70 | Modified Version, except to acknowledge the contribution(s) of the
71 | Copyright Holder(s) and the Author(s) or with their explicit written
72 | permission.
73 |
74 | 5) The Font Software, modified or unmodified, in part or in whole,
75 | must be distributed entirely under this license, and must not be
76 | distributed under any other license. The requirement for fonts to
77 | remain under this license does not apply to any document created
78 | using the Font Software.
79 |
80 | TERMINATION
81 | This license becomes null and void if any of the above conditions are
82 | not met.
83 |
84 | DISCLAIMER
85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93 | OTHER DEALINGS IN THE FONT SOFTWARE.
94 |
--------------------------------------------------------------------------------
/fonts/Quicksand/Quicksand-VariableFont_wght.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/fonts/Quicksand/Quicksand-VariableFont_wght.ttf
--------------------------------------------------------------------------------
/fonts/Quicksand/Quicksand_Bold.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/fonts/Quicksand/Quicksand_Bold.otf
--------------------------------------------------------------------------------
/fonts/Quicksand/README.txt:
--------------------------------------------------------------------------------
1 | Quicksand Variable Font
2 | =======================
3 |
4 | This download contains Quicksand as both a variable font and static fonts.
5 |
6 | Quicksand is a variable font with this axis:
7 | wght
8 |
9 | This means all the styles are contained in a single file:
10 | Quicksand-VariableFont_wght.ttf
11 |
12 | If your app fully supports variable fonts, you can now pick intermediate styles
13 | that aren’t available as static fonts. Not all apps support variable fonts, and
14 | in those cases you can use the static font files for Quicksand:
15 | static/Quicksand-Light.ttf
16 | static/Quicksand-Regular.ttf
17 | static/Quicksand-Medium.ttf
18 | static/Quicksand-SemiBold.ttf
19 | static/Quicksand-Bold.ttf
20 |
21 | Get started
22 | -----------
23 |
24 | 1. Install the font files you want to use
25 |
26 | 2. Use your app's font picker to view the font family and all the
27 | available styles
28 |
29 | Learn more about variable fonts
30 | -------------------------------
31 |
32 | https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
33 | https://variablefonts.typenetwork.com
34 | https://medium.com/variable-fonts
35 |
36 | In desktop apps
37 |
38 | https://theblog.adobe.com/can-variable-fonts-illustrator-cc
39 | https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
40 |
41 | Online
42 |
43 | https://developers.google.com/fonts/docs/getting_started
44 | https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
45 | https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
46 |
47 | Installing fonts
48 |
49 | MacOS: https://support.apple.com/en-us/HT201749
50 | Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
51 | Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
52 |
53 | Android Apps
54 |
55 | https://developers.google.com/fonts/docs/android
56 | https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
57 |
58 | License
59 | -------
60 | Please read the full license text (OFL.txt) to understand the permissions,
61 | restrictions and requirements for usage, redistribution, and modification.
62 |
63 | You can use them freely in your products & projects - print or digital,
64 | commercial or otherwise. However, you can't sell the fonts on their own.
65 |
66 | This isn't legal advice, please consider consulting a lawyer and see the full
67 | license for all details.
68 |
--------------------------------------------------------------------------------
/fonts/Quicksand/static/Quicksand-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/fonts/Quicksand/static/Quicksand-Bold.ttf
--------------------------------------------------------------------------------
/fonts/Quicksand/static/Quicksand-Light.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/fonts/Quicksand/static/Quicksand-Light.ttf
--------------------------------------------------------------------------------
/fonts/Quicksand/static/Quicksand-Medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/fonts/Quicksand/static/Quicksand-Medium.ttf
--------------------------------------------------------------------------------
/fonts/Quicksand/static/Quicksand-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/fonts/Quicksand/static/Quicksand-Regular.ttf
--------------------------------------------------------------------------------
/fonts/Quicksand/static/Quicksand-SemiBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/fonts/Quicksand/static/Quicksand-SemiBold.ttf
--------------------------------------------------------------------------------
/fonts/quicksand-bold.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/fonts/quicksand-bold.zip
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | Jellyfin Rewind
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/javascript.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/main.js:
--------------------------------------------------------------------------------
1 | import './style.css'
2 |
3 | import { reactive, watch, html, } from '@arrow-js/core'
4 |
5 | import * as jellyfinRewind from './src/rewind.js'
6 | import JellyHelper from './src/jelly-helper.js'
7 |
8 | import * as Onboarding from './src/onboarding.js'
9 | import * as Features from './src/features.js'
10 |
11 | document.querySelector('#app').innerHTML = `
12 | `
13 | // {/*
14 | //
15 | //
16 | //
17 | //
18 | //
19 | //
20 | //
Hello Vite!
21 | //
22 | //
23 | //
24 | //
25 | // Click on the Vite logo to learn more
26 | //
27 | //
*/}
28 |
29 | let userInfo = null;
30 | let helper = null;
31 |
32 | let featuresInitialized = false
33 |
34 | window.onload = async () => {
35 |
36 | let config = {
37 | targetRange: {
38 | start: `${import.meta.env.VITE_TARGET_YEAR}-01-01`,
39 | end: `${import.meta.env.VITE_TARGET_YEAR}-12-31`
40 | },
41 | server: {
42 | url: ``,
43 | apiKey: null,
44 | }
45 | }
46 | try {
47 | config = await (await fetch(`/config.json`)).json()
48 | } catch (err) {
49 | console.warn(`Couldn't fetch config:`, err);
50 | }
51 | console.log(`config:`, config)
52 |
53 | window.jellyfinRewind = jellyfinRewind
54 |
55 | // console.log(`target year:`, import.meta.env.VITE_TARGET_YEAR)
56 | console.log(`commit hash:`, __COMMITHASH__)
57 |
58 | jellyfinRewind.auth.config.baseUrl = config?.server?.url ?? ``
59 |
60 | if (jellyfinRewind.auth.restoreSession()) {
61 | console.info(`Session restored!`)
62 | }
63 |
64 | helper = new JellyHelper(jellyfinRewind.auth)
65 | window.helper = helper
66 |
67 | await Onboarding.init(jellyfinRewind.auth)
68 | Onboarding.render()
69 |
70 | }
71 |
72 | function downloadRewindReportData(reportData, skipVerification) {
73 | if (reportData.rawData || skipVerification || confirm(`The report you're about to download is incomplete and missing some data. Please re-generate and download the report without reloading the page in-between. Do you want to download the incomplete report anyway?`)) {
74 | const reportDataString = JSON.stringify(reportData, null, 2)
75 | const blob = new Blob([reportDataString], {type: `application/json`})
76 | const url = URL.createObjectURL(blob)
77 | const a = document.createElement(`a`)
78 | console.log(`jellyfinRewind.auth.config:`, jellyfinRewind.auth.config)
79 | a.href = url
80 | a.download = `jellyfin-rewind-report-${reportData.jellyfinRewindReport.year}_for-${jellyfinRewind.auth.config.user.name}-at-${jellyfinRewind.auth.config.serverInfo.ServerName}_${new Date().toISOString().slice(0, 10)}.json`
81 | a.click()
82 | }
83 |
84 | }
85 | window.downloadRewindReportData = downloadRewindReportData
86 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jellyfin-rewind",
3 | "private": true,
4 | "version": "0.1.0",
5 | "author": "Chaphasilor",
6 | "license": "GPL-3.0",
7 | "description": "",
8 | "type": "module",
9 | "scripts": {
10 | "dev": "vite",
11 | "build": "vite build",
12 | "preview": "vite preview"
13 | },
14 | "devDependencies": {
15 | "autoprefixer": "^10.4.20",
16 | "node-fetch": "^3.3.2",
17 | "postcss": "^8.4.18",
18 | "tailwindcss": "^3.4.15",
19 | "vite": "^5.0.4"
20 | },
21 | "dependencies": {
22 | "@arrow-js/core": "1.0.0-alpha.9",
23 | "blurhash": "^2.0.5",
24 | "chart.js": "^4.4.6",
25 | "motion": "^11.11.17"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "targetRange": {
3 | "start": "2024-01-01",
4 | "end": "2024-12-31"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/public/media/AlbumPlaceholder-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/public/media/AlbumPlaceholder-dark.png
--------------------------------------------------------------------------------
/public/media/AlbumPlaceholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/public/media/AlbumPlaceholder.png
--------------------------------------------------------------------------------
/public/media/ArtistPlaceholder-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/public/media/ArtistPlaceholder-dark.png
--------------------------------------------------------------------------------
/public/media/ArtistPlaceholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/public/media/ArtistPlaceholder.png
--------------------------------------------------------------------------------
/public/media/TrackPlaceholder-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/public/media/TrackPlaceholder-dark.png
--------------------------------------------------------------------------------
/public/media/TrackPlaceholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/public/media/TrackPlaceholder.png
--------------------------------------------------------------------------------
/public/media/banner-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/public/media/banner-dark.png
--------------------------------------------------------------------------------
/public/media/banner-dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/public/media/banner-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/public/media/banner-light.png
--------------------------------------------------------------------------------
/public/media/banner-light.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/public/media/jellyfin-banner-dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | banner-dark
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/public/media/jellyfin-banner-light.svg:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | banner-light
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/public/media/jellyfin-icon-transparent.svg:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | icon-transparent
19 |
20 |
21 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/public/media/jellyfin-rewind-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chaphasilor/jellyfin-rewind/27fa29da71faa3a362e5330ededeb27899b27a9f/public/media/jellyfin-rewind-icon.png
--------------------------------------------------------------------------------
/public/media/jellyfin-rewind-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # Jellyfin Rewind
4 |
5 | ## Welcome to Jellyfin Rewind 2024!
6 |
7 | > [!IMPORTANT]
8 | > Jellyfin Rewind 2025 **will launch on December 31st 2025** (2025-12-31)!
9 | > If you want to be notified when it's time to review your listening habits of this year, **subscribe to release updates** by `watch`ing this repository.
10 | > See you then! - Chaphasilor
11 |
12 | ### How to use
13 |
14 | Because Jellyfin Rewind is web-based and (for now at least) not available as a plugin, it might be a bit tricky to get your browser to communicate with your Jellyfin server. The problem is that browsers won't allow "insecure" requests (HTTP) from a "secure" website (HTTP**S**), or requests from a non-private context (website not within your network) to a private context (Jellyfin server accessed over a local IP address within your network).
15 | So make sure you're not using a local IP address (starts with `192.168.`) or mDNS hostname (something like `jellyfin.local`). If you use something like Tailscale as your VPN, you could use your server's Tailscale IP address.
16 |
17 | If you're unsure what your Jellyfin server is using, but your Jellyfin server is accessible over the internet, simply use the first link (http)!
18 | If that doesn't work, or your server **is NOT** accessible over the internet, you could self-host the Jellyfin Rewind website on your local network, for example on the same server that is running Jellyfin. For that, check out the [GitHub releases page](https://github.com/Chaphasilor/jellyfin-rewind/releases) and either download the zip-archive or use the provided Docker image. The zip-archive will need to be extracted into a folder that is served by a web server, like Apache or Nginx. The Docker image will need a to have port 80 exposed instead.
19 |
20 | ### Links
21 |
22 | **Local Network / Self-Hosting**
23 |
24 | If your Jellyfin server is only accessible on your local network, you will need to self-host Jellyfin Rewind so that it's also accessible on your local network. Otherwise your browser will block the connection.
25 | To do this, check out the [GitHub releases page](https://github.com/Chaphasilor/jellyfin-rewind/releases) and either download the zip-archive or use the [provided Docker image here](https://hub.docker.com/r/chaphasilor/jellyfin-rewind/tags). The zip-archive will need to be extracted into a folder that is served by a web server, like Apache or Nginx. The Docker image will need a to have port 80 exposed instead.
26 |
27 | **HTTP** (works for both http and https Jellyfin servers, as long as they are accessible over the internet):
28 |
29 | *Make sure your browser shows "insecure" / no lock at the top after opening the link, otherwise connecting to your HTTP-only Jellyfin server might not work!*
30 |
31 |
32 |
33 | **HTTPS** (**only use this if your Jellyfin server has an https connection and is accessible over the internet**, this is the best experience):
34 |
35 |
36 |
37 | ### Download your Rewind report!
38 |
39 | **Please, please, please download your Rewind report at the end!**
40 |
41 | Jellyfin's statistics aren't very exhaustive, and any additional data could help offer you more insights during next year's Rewind! Especially if you don't have the *Playback Reporting* plugin installed, this year's Rewind report might come in very handy, so keep it safe!
42 |
43 | If something doesn't work and you can't download the data, I'll be happy to help you resolve the issue.
44 |
45 | ### How does it work?
46 |
47 | Glad you asked!
48 | Essentially, Jellyfin Rewind loads most of the information about your music from your Jellyfin server, processes it on your device, aggregates some nice statistics, and then shows the result to you!
49 |
50 | Your data never leaves your device; it's very similar to using the Jellyfin app on your phone.
51 |
52 | Sadly the build in statistics of Jellyfin are pretty lackluster as of now, even with the *Playback Reporting* plugin, so that *a lot* of data has to be processed on your device. That's why it takes a few seconds to generate your Rewind report.
53 |
54 | For next year, I might release a separate plugin that can use your Jellyfin server in order to crunch the data. This would also solve some of the connection problems that might happen this year. If you're interested in helping me with the plugin, please be sure to reach out!
55 |
56 | ### Can I help out somehow?
57 |
58 | If you know something about web development, are a designer of some sorts, or have experience (or are curious about) developing Jellyfin plugins, I'd love to hear from you! There's so much I want to implement for next year's Jellyfin Rewind, and I need your help to bring all these ideas to life!
59 |
60 | I had many more features planned for this year, but simply didn't have the time. I originally planned to launch back in November, and that obviously didn't work out :)
61 |
62 | Thanks to everyone who uses Jellyfin Rewind, I sincerely hope you enjoyed it as much as I did!
63 | See you next year!!!
64 |
--------------------------------------------------------------------------------
/requests.txt:
--------------------------------------------------------------------------------
1 | http://192.168.31.40:8096/user_usage_stats/submit_custom_query?stamp=1665757270586
2 | {"CustomQueryString":"SELECT count(*) from (SELECT ROWID, count(*) from PlaybackActivity where ItemType=\"Audio\" group by ItemId)","ReplaceUseIrd":false}
3 |
4 | SELECT ROWID, *
5 | FROM PlaybackActivity
6 | WHERE ItemType="Audio"
7 | LIMIT 30
8 |
9 |
10 | SELECT ROWID, count(*) from PlaybackActivity where ItemType="Audio" group by ItemId
11 |
12 |
13 | SELECT count(*) from (SELECT ROWID, count(*) from PlaybackActivity where ItemType="Audio" group by ItemId)
14 |
15 |
16 | SELECT ROWID, *
17 | FROM PlaybackActivity
18 | WHERE ItemType="Audio" and ItemName like "% - %-%"
19 |
20 | http://192.168.31.40:8096/Users/13c597ac83f74af9a2d26235a1c23524/Items?SortBy=Album%2CSortName&SortOrder=Ascending&IncludeItemTypes=Audio&Recursive=true&Fields=AudioInfo%2CParentId&EnableImageTypes=Primary&ids=4673eb1597b5208f999a9a479223302b,c44f444851325ae5ca0acdb3ca5405f2,d437c474d322dc7d261ed810061cd91a
21 |
22 |
23 | SELECT ROWID, SUM(PlayDuration), *
24 | FROM PlaybackActivity
25 | WHERE ItemType="Audio"
26 | GROUP BY ItemId
27 | LIMIT 30
28 |
29 | SELECT ROWID, SUM(PlayDuration) AS TotalDuration, ItemId, ItemName
30 | FROM PlaybackActivity
31 | WHERE ItemType="Audio"
32 | AND UserId="13c597ac83f74af9a2d26235a1c23524"
33 | GROUP BY ItemId
34 |
35 | SELECT ROWID, *
36 | FROM PlaybackActivity
37 | WHERE ItemType="Audio"
38 | AND UserId="13c597ac83f74af9a2d26235a1c23524"
39 | AND datetime(DateCreated) >= datetime('2022-01-01') AND datetime(DateCreated) <= datetime('2022-12-31 23:59:59.9999999')
40 | ORDER BY DateCreated ASC
41 | LIMIT 30
42 |
43 | INSERT INTO PlaybackActivity
44 | (DateCreated, UserId, ItemId, ItemType, ItemName, PlaybackMethod, ClientName, DeviceName, PlayDuration)
45 | VALUES
46 | ( '2024-12-29 17:55:07.0000000', 'c0ffee', '22ed98039ef8db9016f64bba02b36ad5', 'Audio', 'Crankdat, Ace Aura - The Feeling (The Feeling)', 'OfflinePlay', 'Finamp', 'Unknown', 420),
47 | ( '2024-12-29 17:55:12.0000000', 'c0ffee', 'e5262768cd8d18bf6c25dff4a97f2fda', 'Audio', 'Fabian Mazur - Buckwild (The FifthGuys & Coffeeshop Remix) (Buckwild (The FifthGuys & Coffeeshop Remix))', 'OfflinePlay', 'Finamp', 'Unknown', 420)
48 |
--------------------------------------------------------------------------------
/src/aggregate.js:
--------------------------------------------------------------------------------
1 | import { PrimaryImage, BackdropImage, Artist, Album, Track } from './types.js'
2 |
3 | export function generateTopTrackInfo(itemInfo, playbackReportJSON) {
4 | let missingPlaybackReportItems = 0
5 | const topTrackInfo = Object.values(itemInfo).map(item => {
6 |
7 | try {
8 |
9 | const playbackReportItem = playbackReportJSON[item.Id]
10 | const adjustedPlaybackReportPlayCount = playbackReportItem?.Plays?.filter(x => Math.floor(Number(x.duration)) > 0)?.length
11 |
12 | if (!playbackReportItem) {
13 | missingPlaybackReportItems += 1
14 | }
15 |
16 | const track = new Track({
17 | name: item.Name || `Unknown Track`,
18 | id: item.Id,
19 | artistsBaseInfo: item.ArtistItems.map(artist => ({id: artist.Id, name: artist.Name || `Unknown Artist`})),
20 | albumBaseInfo: {
21 | id: item.AlbumId,
22 | name: item.Album || `Unknown Album`,
23 | albumArtistBaseInfo: {
24 | id: item.AlbumArtists?.[0]?.Id || ``,
25 | name: item.AlbumArtists?.[0]?.Name || `Unknown Artist`,
26 | },
27 | },
28 | genreBaseInfo: item.GenreItems?.map(genre => ({id: genre.Id, name: genre.Name || `Unknown Genre`})) || [],
29 | image: new PrimaryImage({
30 | parentItemId: item.ImageTags?.Primary ? item.Id : item.AlbumId,
31 | primaryTag: item.ImageTags?.Primary ? item.ImageTags.Primary : item.AlbumPrimaryImageTag,
32 | blurhash: item.ImageBlurHashes?.Primary?.[item.ImageTags?.Primary],
33 | }),
34 | year: item.PremiereDate ? new Date(item.PremiereDate).getFullYear() : null,
35 | duration: !isNaN(Math.round(item.RunTimeTicks / 10000000)) ? Math.round(item.RunTimeTicks / 10000000) : 0,
36 | skips: {
37 | partial: playbackReportItem?.PartialSkips || 0,
38 | full: playbackReportItem?.FullSkips || 0,
39 | total: (playbackReportItem?.PartialSkips || 0) + (playbackReportItem?.FullSkips || 0),
40 | //TODO compare amount of skips with amount of plays for better data
41 | score: {
42 | jellyfin: 0,
43 | playbackReport: 0,
44 | average: 0,
45 | },
46 | },
47 | playCount: {
48 | jellyfin: item.UserData?.PlayCount || 0,
49 | // playbackReport: Number(playbackReportItem?.TotalPlayCount) || 0,
50 | playbackReport: adjustedPlaybackReportPlayCount || 0,
51 | average: Math.ceil(((item.UserData?.PlayCount || 0) + Number(adjustedPlaybackReportPlayCount || 0))/2),
52 | },
53 | plays: playbackReportItem?.Plays || [],
54 | mostSuccessivePlays: playbackReportItem?.MostSuccessivePlays || null,
55 | lastPlayed: item.UserData?.LastPlayedDate ? new Date(item.UserData.LastPlayedDate) : new Date(0),
56 | totalPlayDuration: {
57 | jellyfin: !isNaN(Number(item.UserData?.PlayCount) * (Number(item.RunTimeTicks) / (10000000 * 60))) ? Number(item.UserData?.PlayCount) * (Number(item.RunTimeTicks) / (10000000 * 60)) : 0, // convert jellyfin's runtime ticks to minutes (https://learn.microsoft.com/en-us/dotnet/api/system.datetime.ticks?view=net-7.0)
58 | playbackReport: !isNaN(Number(playbackReportItem?.TotalDuration) / 60) ? (Number(playbackReportItem?.TotalDuration) / 60 || 0) : 0, // convert to minutes
59 | average: !isNaN(Math.ceil(((Number(item.UserData?.PlayCount) * (Number(item.RunTimeTicks) / (10000000 * 60))) + (Number(playbackReportItem?.TotalDuration) / 60 || 0))/2)) ? Math.ceil(((Number(item.UserData?.PlayCount) * (Number(item.RunTimeTicks) / (10000000 * 60))) + (Number(playbackReportItem?.TotalDuration) / 60 || 0))/2) : 0,
60 | },
61 | isFavorite: item.UserData?.IsFavorite,
62 | })
63 |
64 | track.skips.score.jellyfin = (track.skips.total + 1) * 2 / track.playCount.jellyfin
65 | track.skips.score.playbackReport = (track.skips.total + 1) * 2 / track.playCount.playbackReport
66 | track.skips.score.average = (track.skips.total + 1) * 2 / track.playCount.average
67 |
68 | return track
69 |
70 | } catch (err) {
71 |
72 | console.error(`Error while generating track info:`, err)
73 | throw new Error(`Error while generating track info:`, err)
74 |
75 | }
76 |
77 | })
78 |
79 | console.log(`missingPlaybackReportItems:`, missingPlaybackReportItems)
80 | return topTrackInfo
81 | }
82 |
83 | export function generateAlbumInfo(topTrackInfo, albumInfo) {
84 | const topAlbumInfo = topTrackInfo.reduce((acc, cur) => {
85 | const albumId = cur.albumBaseInfo?.id
86 | const currentAlbumInfo = albumInfo[albumId]
87 | if (!acc[albumId]) {
88 | acc[albumId] = new Album({
89 | id: cur.albumBaseInfo?.id,
90 | name: cur.albumBaseInfo?.name,
91 | artists: new Set(cur.artistsBaseInfo),
92 | albumArtist: cur.albumArtist,
93 | tracks: [cur],
94 | year: currentAlbumInfo?.PremiereDate ? new Date (currentAlbumInfo.PremiereDate) : cur.year,
95 | image: new PrimaryImage({
96 | parentItemId: albumId,
97 | primaryTag: currentAlbumInfo?.ImageTags?.Primary,
98 | blurhash: currentAlbumInfo?.ImageBlurHashes?.Primary?.[currentAlbumInfo?.ImageTags?.Primary],
99 | }),
100 | playCount: {
101 | jellyfin: cur.playCount?.jellyfin,
102 | playbackReport: cur.playCount?.playbackReport,
103 | average: cur.playCount?.average,
104 | },
105 | plays: cur.plays,
106 | lastPlayed: cur.lastPlayed,
107 | totalPlayDuration: {
108 | jellyfin: Number(cur.totalPlayDuration.jellyfin),
109 | playbackReport: Number(cur.totalPlayDuration.playbackReport),
110 | average: Number(cur.totalPlayDuration.average),
111 | },
112 | })
113 | } else {
114 | acc[albumId].tracks.push(cur)
115 | cur.artistsBaseInfo.forEach(artistInfo => acc[albumId].artists.add(artistInfo))
116 | acc[albumId].playCount.jellyfin += Number(cur.playCount?.jellyfin)
117 | acc[albumId].playCount.playbackReport += Number(cur.playCount?.playbackReport)
118 | acc[albumId].playCount.average = Math.ceil(((acc[albumId]?.playCount?.jellyfin || 0) + (acc[albumId]?.playCount?.playbackReport || 0))/2)
119 | acc[albumId].plays.concat(cur.plays)
120 | acc[albumId].lastPlayed = (acc[albumId]?.lastPlayed || 0) > cur.lastPlayed ? (acc[albumId]?.lastPlayed || 0) : cur.lastPlayed
121 | acc[albumId].totalPlayDuration.jellyfin += Number(cur.totalPlayDuration.jellyfin)
122 | acc[albumId].totalPlayDuration.playbackReport += Number(cur.totalPlayDuration.playbackReport)
123 | acc[albumId].totalPlayDuration.average = Math.ceil(((acc[albumId]?.totalPlayDuration?.jellyfin || 0) + (acc[albumId]?.totalPlayDuration?.playbackReport || 0))/2)
124 | }
125 | return acc
126 | }, {})
127 | Object.entries(topAlbumInfo).forEach(([albumId, album]) => {
128 | album.artists = Array.from(album.artists)
129 | })
130 | return topAlbumInfo
131 | }
132 |
133 | export function generateArtistInfo(topTrackInfo, artistInfo) {
134 | const topArtistInfo = topTrackInfo.reduce((acc, cur) => {
135 | cur.artistsBaseInfo.forEach(artist => {
136 | const artistId = artist.id
137 | const currentArtistInfo = artistInfo[artistId]
138 | if (!acc[artistId]) {
139 | acc[artistId] = new Artist({
140 | id: artist.id,
141 | name: artist.name,
142 | tracks: [cur],
143 | images: {
144 | primary: new PrimaryImage({
145 | parentItemId: artistId,
146 | primaryTag: currentArtistInfo?.ImageTags?.Primary,
147 | blurhash: currentArtistInfo?.ImageBlurHashes?.Primary?.[currentArtistInfo.ImageTags.Primary],
148 | }),
149 | backdrop: new BackdropImage({
150 | id: 0,
151 | parentItemId: artistId,
152 | backgroundTag: currentArtistInfo?.BackdropImageTags?.[0],
153 | blurhash: currentArtistInfo?.ImageBlurHashes?.Backdrop?.[currentArtistInfo.BackdropImageTags?.[0]],
154 | }),
155 | },
156 | playCount: {
157 | jellyfin: cur.playCount?.jellyfin,
158 | playbackReport: cur.playCount?.playbackReport,
159 | average: cur.playCount?.average,
160 | },
161 | uniqueTracks: new Set([{id: cur.id, name: cur.name}]),
162 | uniquePlayedTracks: {
163 | jellyfin: new Set(cur.playCount?.jellyfin > 0 ? [{id: cur.id, name: cur.name}] : []),
164 | playbackReport: new Set(cur.playCount?.playbackReport > 0 ? [{id: cur.id, name: cur.name}] : []),
165 | average: new Set(cur.playCount?.average > 0 ? [{id: cur.id, name: cur.name}] : []),
166 | },
167 | plays: cur.plays,
168 | lastPlayed: cur.lastPlayed,
169 | totalPlayDuration: {
170 | jellyfin: Number(cur.totalPlayDuration.jellyfin),
171 | playbackReport: Number(cur.totalPlayDuration.playbackReport),
172 | average: Number(cur.totalPlayDuration.average),
173 | },
174 | })
175 |
176 | } else {
177 | acc[artistId].tracks.push(cur)
178 | acc[artistId].playCount.jellyfin += Number(cur.playCount?.jellyfin)
179 | acc[artistId].playCount.playbackReport += Number(cur.playCount?.playbackReport)
180 | acc[artistId].playCount.average = Math.ceil(((acc[artistId]?.playCount?.jellyfin || 0) + (acc[artistId]?.playCount?.playbackReport || 0))/2)
181 | acc[artistId].uniqueTracks.add({id: cur.id, name: cur.name})
182 | if (cur.playCount?.jellyfin > 0) {
183 | acc[artistId].uniquePlayedTracks.jellyfin.add({id: cur.id, name: cur.name})
184 | }
185 | if (cur.playCount?.playbackReport > 0) {
186 | acc[artistId].uniquePlayedTracks.playbackReport.add({id: cur.id, name: cur.name})
187 | }
188 | if (cur.playCount?.average > 0) {
189 | acc[artistId].uniquePlayedTracks.average.add({id: cur.id, name: cur.name})
190 | }
191 | acc[artistId].plays.concat(cur.plays)
192 | acc[artistId].lastPlayed = (acc[artistId]?.lastPlayed || 0) > cur.lastPlayed ? (acc[artistId]?.lastPlayed || 0) : cur.lastPlayed
193 | acc[artistId].totalPlayDuration.jellyfin += Number(cur.totalPlayDuration.jellyfin)
194 | acc[artistId].totalPlayDuration.playbackReport += Number(cur.totalPlayDuration.playbackReport)
195 | acc[artistId].totalPlayDuration.average = Math.ceil(((acc[artistId]?.totalPlayDuration?.jellyfin || 0) + (acc[artistId]?.totalPlayDuration?.playbackReport || 0))/2)
196 | }
197 | })
198 | return acc
199 | }, {})
200 |
201 | Object.entries(topArtistInfo).forEach(([artistId, artist]) => {
202 | artist.uniqueTracks = Array.from(artist.uniqueTracks)
203 | artist.uniquePlayedTracks = {
204 | jellyfin: Array.from(artist.uniquePlayedTracks.jellyfin),
205 | playbackReport: Array.from(artist.uniquePlayedTracks.playbackReport),
206 | average: Array.from(artist.uniquePlayedTracks.average),
207 | }
208 | })
209 |
210 | return topArtistInfo
211 | }
212 |
213 | export function generateGenreInfo(topTrackInfo) {
214 | const topGenreInfo = topTrackInfo.reduce((acc, cur) => {
215 | cur.genreBaseInfo.forEach(genre => {
216 | const genreId = genre.id
217 | if (!acc[genreId]) {
218 | acc[genreId] = new Artist({
219 | id: genre.id,
220 | name: genre.name,
221 | tracks: [cur],
222 | image: null,
223 | playCount: {
224 | jellyfin: cur.playCount?.jellyfin,
225 | playbackReport: cur.playCount?.playbackReport,
226 | average: cur.playCount?.average,
227 | },
228 | uniqueTracks: new Set([{id: cur.id, name: cur.name}]),
229 | uniquePlayedTracks: {
230 | jellyfin: new Set(cur.playCount?.jellyfin > 0 ? [{id: cur.id, name: cur.name}] : []),
231 | playbackReport: new Set(cur.playCount?.playbackReport > 0 ? [{id: cur.id, name: cur.name}] : []),
232 | average: new Set(cur.playCount?.average > 0 ? [{id: cur.id, name: cur.name}] : []),
233 | },
234 | plays: cur.plays,
235 | lastPlayed: cur.lastPlayed,
236 | totalPlayDuration: {
237 | jellyfin: Number(cur.totalPlayDuration.jellyfin),
238 | playbackReport: Number(cur.totalPlayDuration.playbackReport),
239 | average: Number(cur.totalPlayDuration.average),
240 | },
241 | })
242 | } else {
243 | acc[genreId].tracks.push(cur)
244 | acc[genreId].playCount.jellyfin += Number(cur.playCount?.jellyfin)
245 | acc[genreId].playCount.playbackReport += Number(cur.playCount?.playbackReport)
246 | acc[genreId].playCount.average = Math.ceil(((acc[genreId]?.playCount?.jellyfin || 0) + (acc[genreId]?.playCount?.playbackReport || 0))/2)
247 | acc[genreId].uniqueTracks.add({id: cur.id, name: cur.name})
248 | if (cur.playCount?.jellyfin > 0) {
249 | acc[genreId].uniquePlayedTracks.jellyfin.add({id: cur.id, name: cur.name})
250 | }
251 | if (cur.playCount?.playbackReport > 0) {
252 | acc[genreId].uniquePlayedTracks.playbackReport.add({id: cur.id, name: cur.name})
253 | }
254 | if (cur.playCount?.average > 0) {
255 | acc[genreId].uniquePlayedTracks.average.add({id: cur.id, name: cur.name})
256 | }
257 | acc[genreId].plays.concat(cur.plays)
258 | acc[genreId].lastPlayed = (acc[genreId]?.lastPlayed || 0) > cur.lastPlayed ? (acc[genreId]?.lastPlayed || 0) : cur.lastPlayed
259 | acc[genreId].totalPlayDuration.jellyfin += Number(cur.totalPlayDuration.jellyfin)
260 | acc[genreId].totalPlayDuration.playbackReport += Number(cur.totalPlayDuration.playbackReport)
261 | acc[genreId].totalPlayDuration.average = Math.ceil(((acc[genreId]?.totalPlayDuration?.jellyfin || 0) + (acc[genreId]?.totalPlayDuration?.playbackReport || 0))/2)
262 | }
263 | })
264 | return acc
265 | }, {})
266 |
267 | Object.entries(topGenreInfo).forEach(([genreId, genre]) => {
268 | genre.uniqueTracks = Array.from(genre.uniqueTracks)
269 | genre.uniquePlayedTracks = {
270 | jellyfin: Array.from(genre.uniquePlayedTracks.jellyfin),
271 | playbackReport: Array.from(genre.uniquePlayedTracks.playbackReport),
272 | average: Array.from(genre.uniquePlayedTracks.average),
273 | }
274 | })
275 |
276 | return topGenreInfo
277 | }
278 |
279 | export function generateTotalStats(topTrackInfo, enhancedPlaybackReport) {
280 | const totalStats = topTrackInfo.reduce((acc, cur) => {
281 | acc.totalPlayCount.jellyfin += Number(cur.playCount?.jellyfin) || 0
282 | acc.totalPlayCount.playbackReport += Number(cur.playCount?.playbackReport) || 0
283 | acc.totalPlayCount.average = Math.ceil((acc.totalPlayCount.jellyfin + acc.totalPlayCount.playbackReport)/2)
284 | acc.totalPlayDuration.jellyfin += Number(cur.totalPlayDuration.jellyfin)
285 | acc.totalPlayDuration.playbackReport += Number(cur.totalPlayDuration.playbackReport)
286 | acc.totalPlayDuration.average = Math.ceil((acc.totalPlayDuration.jellyfin + acc.totalPlayDuration.playbackReport)/2)
287 | acc.totalSkips.partial += Number(cur.skips?.partial) || 0
288 | acc.totalSkips.full += Number(cur.skips?.full) || 0
289 | acc.totalSkips.total += Number(cur.skips?.total) || 0
290 | acc.uniqueTracks.add(cur.id)
291 | acc.uniqueAlbums.add(cur.albumBaseInfo?.id)
292 | cur.artistsBaseInfo.forEach(artist =>
293 | acc.uniqueArtists.add(artist.id)
294 | )
295 |
296 | cur.plays.forEach(play => {
297 | acc.playbackMethods.playCount[play.method] += 1
298 | acc.playbackMethods.duration[play.method] += Number(play.duration) / 60 // convert to minutes
299 |
300 | acc.locations.devices[play.device] = acc.locations.devices[play.device] ? acc.locations.devices[play.device] + 1 : 1
301 | acc.locations.clients[play.client] = acc.locations.clients[play.client] ? acc.locations.clients[play.client] + 1 : 1
302 | if (!acc.locations.combinations[`${play.device} - ${play.client}`]) {
303 | acc.locations.combinations[`${play.device} - ${play.client}`] = {
304 | device: play.device,
305 | client: play.client,
306 | playCount: 1,
307 | }
308 | } else {
309 | acc.locations.combinations[`${play.device} - ${play.client}`].playCount += 1
310 | }
311 |
312 | acc.totalMusicDays.add(play.date?.toLocaleDateString())
313 |
314 | if (!acc.minutesPerDay[play.date?.toLocaleDateString()]) {
315 | acc.minutesPerDay[play.date?.toLocaleDateString()] = Number(play.duration) / 60.0 // convert to minutes
316 | } else {
317 | acc.minutesPerDay[play.date?.toLocaleDateString()] += Number(play.duration) / 60.0 // convert to minutes
318 | }
319 | })
320 |
321 | if (cur.mostSuccessivePlays && (!acc.mostSuccessivePlays || cur.mostSuccessivePlays.playCount > acc.mostSuccessivePlays.playCount)) {
322 | acc.mostSuccessivePlays = {
323 | track: cur,
324 | name: cur.name,
325 | artists: cur.artistsBaseInfo,
326 | albumArtist: cur.albumBaseInfo?.albumArtistBaseInfo,
327 | image: cur.image,
328 | playCount: cur.mostSuccessivePlays.playCount,
329 | totalDuration: cur.mostSuccessivePlays.totalDuration / 60, // convert to minutes
330 | }
331 | }
332 |
333 | if (cur.isFavorite) {
334 | acc.libraryStats.tracks.favorite += 1
335 | }
336 | acc.libraryStats.trackLength.lengths[cur.id] = cur.duration
337 | acc.libraryStats.totalRuntime += Number(cur.duration)
338 |
339 | return acc
340 | }, {
341 | totalPlayCount: {
342 | jellyfin: 0,
343 | playbackReport: 0,
344 | average: 0,
345 | },
346 | totalPlayDuration: {
347 | jellyfin: 0,
348 | playbackReport: 0,
349 | },
350 | totalSkips: {
351 | partial: 0,
352 | full: 0,
353 | total: 0,
354 | },
355 | uniqueTracks: new Set(),
356 | uniqueAlbums: new Set(),
357 | uniqueArtists: new Set(),
358 | playbackMethods: {
359 | playCount: {
360 | directPlay: 0,
361 | directStream: 0,
362 | transcode: 0,
363 | },
364 | duration: {
365 | directPlay: 0,
366 | directStream: 0,
367 | transcode: 0,
368 | },
369 | },
370 | locations: {
371 | devices: {},
372 | clients: {},
373 | combinations: {},
374 | },
375 | mostSuccessivePlays: null,
376 | totalMusicDays: new Set(),
377 | minutesPerDay: {},
378 | libraryStats: {
379 | tracks: {
380 | total: 0,
381 | favorite: 0,
382 | },
383 | albums: {
384 | total: 0,
385 | },
386 | artists: {
387 | total: 0,
388 | },
389 | trackLength: {
390 | mean: 0,
391 | median: 0,
392 | min: 0,
393 | max: 0,
394 | lengths: {},
395 | },
396 | totalRuntime: 0,
397 | },
398 | })
399 |
400 | console.log(`enhancedPlaybackReport:`, enhancedPlaybackReport)
401 | console.log(`enhancedPlaybackReport.notFound:`, enhancedPlaybackReport.notFound)
402 | enhancedPlaybackReport.notFound.forEach((item) => {
403 | totalStats.totalPlayCount.playbackReport += 1
404 | totalStats.totalPlayCount.average = Math.ceil((totalStats.totalPlayCount.jellyfin + totalStats.totalPlayCount.playbackReport)/2)
405 | totalStats.totalPlayDuration.playbackReport += Number(item.PlayDuration) / 60
406 | totalStats.totalPlayDuration.average = Math.ceil((totalStats.totalPlayDuration.jellyfin + totalStats.totalPlayDuration.playbackReport)/2)
407 | })
408 |
409 | totalStats.uniqueTracks = totalStats.uniqueTracks.size
410 | totalStats.uniqueAlbums = totalStats.uniqueAlbums.size
411 | totalStats.uniqueArtists = totalStats.uniqueArtists.size
412 |
413 | totalStats.totalMusicDays = totalStats.totalMusicDays.size
414 |
415 | totalStats.minutesPerDay = {
416 | mean: Object.values(totalStats.minutesPerDay).reduce((acc, cur) => acc + cur, 0) / Object.values(totalStats.minutesPerDay).length,
417 | median: Object.values(totalStats.minutesPerDay).length % 2 === 0 ? (Object.values(totalStats.minutesPerDay)[Object.values(totalStats.minutesPerDay).length / 2] + Object.values(totalStats.minutesPerDay)[Object.values(totalStats.minutesPerDay).length / 2 - 1]) / 2 : Object.values(totalStats.minutesPerDay)[Math.floor(Object.values(totalStats.minutesPerDay).length / 2)],
418 | }
419 |
420 | totalStats.libraryStats.tracks.total = totalStats.uniqueTracks
421 | totalStats.libraryStats.albums.total = totalStats.uniqueAlbums
422 | totalStats.libraryStats.artists.total = totalStats.uniqueArtists
423 | totalStats.libraryStats.trackLength.mean = totalStats.libraryStats.totalRuntime / totalStats.libraryStats.tracks.total
424 | const sortedTrackLengths = Object.keys(totalStats.libraryStats.trackLength.lengths).sort((a, b) => totalStats.libraryStats.trackLength.lengths[a] - totalStats.libraryStats.trackLength.lengths[b])
425 | totalStats.libraryStats.trackLength.median = sortedTrackLengths.length % 2 === 0 ? (totalStats.libraryStats.trackLength.lengths[sortedTrackLengths[sortedTrackLengths.length / 2]] + totalStats.libraryStats.trackLength.lengths[sortedTrackLengths[sortedTrackLengths.length / 2 - 1]]) / 2 : totalStats.libraryStats.trackLength.lengths[sortedTrackLengths[Math.floor(sortedTrackLengths.length / 2)]]
426 | totalStats.libraryStats.trackLength.min = totalStats.libraryStats.trackLength.lengths[sortedTrackLengths[0]]
427 | totalStats.libraryStats.trackLength.max = totalStats.libraryStats.trackLength.lengths[sortedTrackLengths[sortedTrackLengths.length - 1]]
428 |
429 | delete totalStats.libraryStats.trackLength.lengths
430 |
431 | return totalStats
432 | }
433 |
434 | export function getTopItems(itemInfo, { by = `duration`, lowToHigh = false, limit = 25, dataSource = `average` }) {
435 | console.log(`itemInfo:`, itemInfo)
436 | let topItems
437 | if (!Array.isArray(itemInfo)) {
438 | topItems = Object.values(itemInfo)
439 | } else {
440 | topItems = [...itemInfo]
441 | }
442 | // console.log(`topItems[0]:`, JSON.stringify(topItems[0]))
443 |
444 | topItems.sort((a, b) => {
445 | if (by === `duration`) {
446 | let aDuration = 0, bDuration = 0;
447 | aDuration = a.totalPlayDuration[dataSource]
448 | bDuration = b.totalPlayDuration[dataSource]
449 | const result = bDuration - aDuration
450 | if (lowToHigh) {
451 | return result * -1
452 | }
453 | return result
454 | } else if (by === `playCount`) {
455 | let aPlayCount = 0, bPlayCount = 0;
456 | aPlayCount = a.playCount[dataSource]
457 | bPlayCount = b.playCount[dataSource]
458 | const result = bPlayCount - aPlayCount
459 | if (lowToHigh) {
460 | return result * -1
461 | }
462 | return result
463 | } else if (by === `lastPlayed`) {
464 | const result = b.lastPlayed - a.lastPlayed
465 | if (lowToHigh) {
466 | return result * -1
467 | }
468 | return result
469 | } else if (by === `skips`) {
470 | let result = b.playCount[dataSource] <= 2 ?
471 | (a.playCount[dataSource] <= 2 ?
472 | 0 :
473 | -1
474 | ) :
475 | (a.playCount[dataSource] <= 2 ?
476 | 1 :
477 | lowToHigh ? (a.skips.score[dataSource] - b.skips.score[dataSource]) : (b.skips.score[dataSource] - a.skips.score[dataSource]))
478 | return result
479 | } else if (by === `skips.partial`) {
480 | const result = b.skips.partial - a.skips.partial
481 | if (lowToHigh) {
482 | return result * -1
483 | }
484 | return result
485 | } else if (by === `skips.full`) {
486 | const result = b.skips.full - a.skips.full
487 | if (lowToHigh) {
488 | return result * -1
489 | }
490 | return result
491 | }
492 | })
493 | // console.log(`topItems[0]:`, JSON.stringify(topItems[0]))
494 | return topItems.slice(0, limit)
495 | }
496 |
497 | export function generateTotalPlaybackDurationByMonth(indexedPlaybackReport) {
498 | const totalPlaybackDurationByMonth = Object.values(indexedPlaybackReport).reduce((acc, cur) => {
499 | cur.Plays.forEach(play => {
500 | const month = play.date?.getMonth()
501 | acc[month] += Number(play.duration)
502 | })
503 | return acc
504 | }, {0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0, 11: 0})
505 | Object.keys(totalPlaybackDurationByMonth).forEach(month => {
506 | totalPlaybackDurationByMonth[month] = Math.ceil(totalPlaybackDurationByMonth[month] / 60) // convert to minutes
507 | })
508 | return totalPlaybackDurationByMonth
509 | }
510 |
--------------------------------------------------------------------------------
/src/auth.js:
--------------------------------------------------------------------------------
1 | export default class Auth {
2 |
3 | constructor() {
4 | this.baseConfig = {
5 | baseUrl: ``,
6 | serverInfo: null,
7 | user: null,
8 | defaultHeaders: {},
9 | }
10 | this.config = this.baseConfig
11 | }
12 |
13 | async connectToServer(serverUrl) {
14 |
15 | this.config.baseUrl = serverUrl;
16 |
17 | let data;
18 |
19 | try {
20 |
21 | const response = await fetch(`${this.config.baseUrl}/System/Info/Public`, {
22 | method: 'GET',
23 | headers: {...this.config.defaultHeaders},
24 | })
25 |
26 | data = await response.json()
27 | data.PublicAddress = serverUrl;
28 | this.setDefaultHeaders()
29 |
30 | } catch (err) {
31 | throw new Error(err);
32 | }
33 |
34 | const semverMajor = parseInt(data.Version?.split('.')[0]);
35 | const semverMinor = parseInt(data.Version?.split('.')[1]);
36 | const isServerVersionSupported =
37 | semverMajor > 10 || (semverMajor === 10 && semverMinor >= 7);
38 |
39 | if (isServerVersionSupported) {
40 | if (!data.StartupWizardCompleted) {
41 | throw new Error(`You need to complete the startup wizard before using Jellyfin Rewind`);
42 | } else {
43 |
44 | if (!this.config.serverInfo) {
45 | this.config.serverInfo = data;
46 | }
47 |
48 | }
49 | } else {
50 | throw new Error('Server version is too low');
51 | }
52 | }
53 |
54 | setDefaultHeaders(accessToken = ``) {
55 |
56 | if (!accessToken) {
57 | accessToken = this.config.user?.token || ``;
58 | }
59 |
60 | const deviceProfile = {
61 | clientName: `Jellyfin Rewind`,
62 | clientVersion: `0.2024.0`,
63 | deviceName: `Chrome`,
64 | deviceId: `90a83627-401a-4f19-bf93-be8ccf521b27`,
65 | }
66 |
67 | const token = `MediaBrowser Client="${deviceProfile.clientName}", Device="${deviceProfile.deviceName}", DeviceId="${deviceProfile.deviceId}", Version="${deviceProfile.clientVersion}", Token="${accessToken}"`;
68 |
69 | this.config.defaultHeaders['Authorization'] = token;
70 | }
71 |
72 | async fetchUsers() {
73 |
74 | const response = await fetch(`${this.config.baseUrl}/Users/Public`, {
75 | method: 'GET',
76 | headers: {
77 | ...this.config.defaultHeaders,
78 | 'Content-Type': `application/json`,
79 | },
80 | })
81 |
82 | const json = await response.json()
83 | return json
84 |
85 | }
86 |
87 | async authenticateUser(username, password) {
88 |
89 | console.log(`this.config.defaultHeaders:`, this.config.defaultHeaders)
90 |
91 | const response = await fetch(`${this.config.baseUrl}/Users/AuthenticateByName`, {
92 | method: 'POST',
93 | headers: {
94 | ...this.config.defaultHeaders,
95 | 'Content-Type': `application/json`,
96 | },
97 | body: JSON.stringify({
98 | Username: username,
99 | Pw: password,
100 | }),
101 | })
102 |
103 | if (response.status !== 200) {
104 | if (response.status === 401) {
105 | throw new Error(`Login failed. Wrong password?`)
106 | }
107 | throw new Error(`Authentication failed: ${await response.text()}`);
108 | }
109 |
110 | const json = await response.json()
111 |
112 | this.config.user = {
113 | token: json.AccessToken,
114 | id: json.User.Id,
115 | name: json.User.Name,
116 | primaryImageTag: json.User.PrimaryImageTag,
117 | sessionId: json.SessionInfo.Id,
118 | isAdmin: json.User.Policy.IsAdministrator,
119 | };
120 |
121 | this.setDefaultHeaders(this.config.user.token);
122 |
123 | }
124 |
125 | async authenticateUserViaToken(token) {
126 |
127 | this.setDefaultHeaders(token)
128 |
129 | console.log(`this.config.defaultHeaders:`, this.config.defaultHeaders)
130 |
131 | const response = await fetch(`${this.config.baseUrl}/Users/Me`, {
132 | method: 'GET',
133 | headers: {
134 | ...this.config.defaultHeaders,
135 | },
136 | })
137 |
138 | const json = await response.json()
139 |
140 | if (response.status !== 200) {
141 | throw new Error(`Authentication failed: ${JSON.stringify(json)}`);
142 | }
143 |
144 | this.config.user = {
145 | token: token,
146 | id: json.Id,
147 | name: json.Name,
148 | primaryImageTag: json.PrimaryImageTag,
149 | sessionId: null,
150 | isAdmin: json.Policy.IsAdministrator,
151 | };
152 |
153 | this.setDefaultHeaders(this.config.user.token);
154 |
155 | }
156 |
157 | saveSession() {
158 | localStorage.setItem('session', JSON.stringify(this.config));
159 | }
160 |
161 | restoreSession() {
162 | const session = localStorage.getItem('session');
163 | if (session) {
164 | this.config = JSON.parse(session);
165 | this.setDefaultHeaders();
166 | return true;
167 | }
168 | return false
169 | }
170 |
171 | destroySession() {
172 | localStorage.removeItem('session');
173 | localStorage.removeItem('rewindReport');
174 | localStorage.removeItem('rewindReportLight');
175 | localStorage.removeItem('rewindReportDownloaded');
176 | this.config = this.baseConfig;
177 | }
178 |
179 | }
180 |
--------------------------------------------------------------------------------
/src/delta.js:
--------------------------------------------------------------------------------
1 | export function importRewindReport(fileHandle) {
2 | return new Promise((resolve, reject) => {
3 | const reader = new FileReader();
4 | reader.onload = () => {
5 | resolve(JSON.parse(reader.result));
6 | };
7 | reader.onerror = () => {
8 | reader.abort();
9 | reject(new Error("Error loading rewind report"));
10 | };
11 | reader.readAsText(fileHandle);
12 | });
13 | }
14 |
15 | export async function getFeatureDelta(oldReport, newReport) {
16 |
17 | console.log(`oldReport:`, oldReport)
18 | console.log(`newReport:`, newReport)
19 |
20 | // calculate difference in listening activity
21 | const uniquePlays = {
22 | albums: newReport.jellyfinRewindReport.generalStats.uniqueAlbumsPlayed - oldReport.jellyfinRewindReport.generalStats.uniqueAlbumsPlayed,
23 | artists: newReport.jellyfinRewindReport.generalStats.uniqueArtistsPlayed - oldReport.jellyfinRewindReport.generalStats.uniqueArtistsPlayed,
24 | tracks: newReport.jellyfinRewindReport.generalStats.uniqueTracksPlayed - oldReport.jellyfinRewindReport.generalStats.uniqueTracksPlayed,
25 | }
26 |
27 | const totalPlays = {
28 | average: newReport.jellyfinRewindReport.generalStats.totalPlays.average - oldReport.jellyfinRewindReport.generalStats.totalPlays.average,
29 | jellyfin: newReport.jellyfinRewindReport.generalStats.totalPlays.jellyfin - oldReport.jellyfinRewindReport.generalStats.totalPlays.jellyfin,
30 | playbackReport: newReport.jellyfinRewindReport.generalStats.totalPlays.playbackReport - oldReport.jellyfinRewindReport.generalStats.totalPlays.playbackReport,
31 | }
32 |
33 | // favorites difference
34 | const favoriteDifference = (newReport.jellyfinRewindReport?.libraryStats?.tracks?.favorite ?? 0) - (oldReport.jellyfinRewindReport?.libraryStats?.tracks?.favorite ?? 0)
35 |
36 | const listeningActivityDifference = {
37 | uniquePlays: uniquePlays,
38 | totalPlays: totalPlays,
39 | }
40 |
41 | return {
42 | listeningActivityDifference,
43 | favoriteDifference,
44 | year: oldReport.jellyfinRewindReport.year,
45 | }
46 |
47 | }
48 |
--------------------------------------------------------------------------------
/src/jelly-helper.js:
--------------------------------------------------------------------------------
1 | import { decode as decodeBlurhash } from 'blurhash';
2 |
3 | export default class JellyHelper {
4 |
5 | constructor(auth) {
6 | this.auth = auth;
7 | }
8 |
9 | loadImage(elements, imageInfo, type = `track`, isDarkMode = false) {
10 |
11 | if (!Array.isArray(elements)) {
12 | elements = [elements];
13 | }
14 |
15 | elements = elements.filter(element => !!element)
16 |
17 | const blurhash = imageInfo?.blurhash
18 | const primaryTag = imageInfo?.primaryTag
19 | const parentItemId = imageInfo?.parentItemId
20 | const resolution = 256
21 |
22 | if (blurhash) {
23 | const dataUri = blurhashToDataURI(blurhash)
24 | elements.forEach(element => {
25 | element.src = dataUri
26 | })
27 | } else {
28 | console.warn(`No blurhash found for item`)
29 | elements.forEach(element => {
30 | switch (type) {
31 | case `track`:
32 | element.src = `/media/TrackPlaceholder${isDarkMode ? `-dark` : ``}.png`
33 | break;
34 | case `artist`:
35 | element.src = `/media/ArtistPlaceholder${isDarkMode ? `-dark` : ``}.png`
36 | break;
37 | case `album`:
38 | element.src = `/media/AlbumPlaceholder${isDarkMode ? `-dark` : ``}.png`
39 | break;
40 |
41 | default:
42 | break;
43 | }
44 | })
45 | }
46 |
47 | if (parentItemId || type === `user`) {
48 | let url = `${this.auth.config.serverInfo.PublicAddress}/Items/${parentItemId}/Images/Primary?MaxWidth=${resolution}&MaxHeight=${resolution}`
49 |
50 | if (type === `user`) {
51 | url = `${this.auth.config.serverInfo.PublicAddress}/Users/${parentItemId}/Images/Primary?MaxWidth=${resolution}&MaxHeight=${resolution}`
52 | }
53 |
54 | if (primaryTag) {
55 | url += `&tag=${primaryTag}`
56 | }
57 |
58 | fetch(url, {
59 | method: `GET`,
60 | headers: {
61 | ...this.auth.config.defaultHeaders,
62 | },
63 | })
64 | .then(response => {
65 | if (response.ok) {
66 | const contentType = response.headers.get(`content-type`)
67 | if (contentType && contentType.includes(`image`)) {
68 | return response.blob()
69 | }
70 | }
71 | })
72 | .then(blob => {
73 | if (blob) {
74 | const objectUrl = URL.createObjectURL(blob)
75 | elements.forEach(element => {
76 | element.src = objectUrl
77 | })
78 | }
79 | })
80 | } else {
81 | console.warn(`No primary image found for item`)
82 | elements.forEach(element => {
83 | switch (type) {
84 | case `track`:
85 | element.src = `/media/TrackPlaceholder${isDarkMode ? `-dark` : ``}.png`
86 | break;
87 | case `artist`:
88 | element.src = `/media/ArtistPlaceholder${isDarkMode ? `-dark` : ``}.png`
89 | break;
90 | case `album`:
91 | element.src = `/media/AlbumPlaceholder${isDarkMode ? `-dark` : ``}.png`
92 | break;
93 | case `user`:
94 | element.src = `/media/ArtistPlaceholder${isDarkMode ? `-dark` : ``}.png`
95 | break;
96 |
97 | default:
98 | break;
99 | }
100 | })
101 | }
102 |
103 | }
104 |
105 | async loadAudio(element, audioInfo) {
106 |
107 | // check if audio element is already loaded/playing
108 | if (element.src) {
109 | element.pause()
110 | element.removeAttribute(`src`)
111 | }
112 |
113 | const params = {
114 | 'UserId': this.auth.config.user.id,
115 | 'DeviceId': this.auth.config.user.deviceId,
116 | 'api_key': this.auth.config.user.token,
117 | 'Container': `opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg`, // limit to mp3 for best support
118 | 'TranscodingContainer': `ts`,
119 | 'TranscodingProtocol': `hls`,
120 | 'AudioCodec': `aac`,
121 | 'EnableRedirection': `true`,
122 | 'EnableRemoteMedia': `true`,
123 | }
124 |
125 | element.src = `${this.auth.config.serverInfo.PublicAddress}/Audio/${audioInfo.id}/universal?${Object.entries(params).map(([key, value]) => `${key}=${value}`).join(`&`)}`
126 | await element.load()
127 | }
128 |
129 | // a "group" is something that doesn't reference a specific track, e.g. an album, artist, playlist, genre, etc.
130 | loadTracksForGroup(groupId, groupType) {
131 |
132 | let url = ``
133 |
134 | switch (groupType) {
135 | case `artist`:
136 | url = `${this.auth.config.serverInfo.PublicAddress}/Users/${this.auth.config.user.id}/Items?ArtistIds=${groupId}&Filters=IsNotFolder&Recursive=true&SortBy=PlayCount&MediaTypes=Audio&Limit=20&Fields=Chapters&ExcludeLocationTypes=Virtual&EnableTotalRecordCount=false&CollapseBoxSetItems=false`
137 | break;
138 | case `album`:
139 | url = `${this.auth.config.serverInfo.PublicAddress}/Users/${this.auth.config.user.id}/Items?ParentId=${groupId}&Filters=IsNotFolder&Recursive=true&SortBy=PlayCount&MediaTypes=Audio&Limit=20&Fields=Chapters&ExcludeLocationTypes=Virtual&EnableTotalRecordCount=false&CollapseBoxSetItems=false`
140 | break;
141 | case `genre`:
142 | url = `${this.auth.config.serverInfo.PublicAddress}/Users/${this.auth.config.user.id}/Items?GenreIds=${groupId}&Filters=IsNotFolder&Recursive=true&SortBy=PlayCount&MediaTypes=Audio&Limit=20&Fields=Chapters&ExcludeLocationTypes=Virtual&EnableTotalRecordCount=false&CollapseBoxSetItems=false`
143 | break;
144 | case `playlist`:
145 | url = `${this.auth.config.serverInfo.PublicAddress}/Users/${this.auth.config.user.id}/Items?ParentId=${groupId}&Filters=IsNotFolder&Recursive=true&SortBy=PlayCount&MediaTypes=Audio&Limit=20&Fields=Chapters&ExcludeLocationTypes=Virtual&EnableTotalRecordCount=false&CollapseBoxSetItems=false`
146 | break;
147 |
148 | default:
149 | break;
150 | }
151 | return fetch(url, {
152 | method: `GET`,
153 | headers: {
154 | ...this.auth.config.defaultHeaders,
155 | },
156 | })
157 | .then(response => {
158 | if (response.ok) {
159 | return response.json()
160 | }
161 | })
162 | .then(json => {
163 | return json.Items
164 | })
165 | }
166 |
167 | async checkIfPlaybackReportingInstalled() {
168 |
169 | const pluginsResponse = await fetch(`${this.auth.config.serverInfo.PublicAddress}/Plugins`, {
170 | method: `GET`,
171 | headers: {
172 | ...this.auth.config.defaultHeaders,
173 | },
174 | })
175 | const pluginsJson = await pluginsResponse.json()
176 |
177 | const playbackReportingPluginInstallation = pluginsJson.find(plugin => plugin.Name === `Playback Reporting`)
178 | if (!playbackReportingPluginInstallation) {
179 | return {
180 | installed: false,
181 | restartRequired: false,
182 | disabled: false,
183 | }
184 | }
185 |
186 | if (playbackReportingPluginInstallation.Status === `Restart`) {
187 | return {
188 | installed: true,
189 | version: playbackReportingPluginInstallation.Version,
190 | id: playbackReportingPluginInstallation.Id,
191 | restartRequired: true,
192 | disabled: false,
193 | }
194 | }
195 |
196 | if (playbackReportingPluginInstallation.Status === `Disabled`) {
197 | return {
198 | installed: true,
199 | version: playbackReportingPluginInstallation.Version,
200 | id: playbackReportingPluginInstallation.Id,
201 | restartRequired: false,
202 | disabled: true,
203 | }
204 | }
205 |
206 | const playbackReportingSettingsResponse = await fetch(`${this.auth.config.serverInfo.PublicAddress}/System/Configuration/playback_reporting`, {
207 | method: `GET`,
208 | headers: {
209 | ...this.auth.config.defaultHeaders,
210 | },
211 | })
212 | const playbackReportingSettingsJson = await playbackReportingSettingsResponse.json()
213 |
214 | let playbackReportingIgnoredUsersJson = []
215 | try {
216 | const playbackReportingIgnoredUsersResponse = await fetch(`${this.auth.config.serverInfo.PublicAddress}/user_usage_stats/user_list`, {
217 | method: `GET`,
218 | headers: {
219 | ...this.auth.config.defaultHeaders,
220 | },
221 | })
222 | playbackReportingIgnoredUsersJson = await playbackReportingIgnoredUsersResponse.json()
223 | } catch (err) {
224 | console.warn(`Couldn't fetch playback reporting ignored users:`, err)
225 | }
226 |
227 | return {
228 | installed: true,
229 | version: playbackReportingPluginInstallation.Version,
230 | id: playbackReportingPluginInstallation.Id,
231 | restartRequired: false,
232 | disabled: false,
233 | settings: {
234 | raw: playbackReportingSettingsJson,
235 | retentionInterval: Number(playbackReportingSettingsJson.MaxDataAge),
236 | },
237 | ignoredUsers: playbackReportingIgnoredUsersJson.filter(user => user.in_list).map(user => ({ id: user.id, name: user.name })),
238 | }
239 |
240 | }
241 |
242 | // requires administrator account
243 | async installPlaybackReportingPlugin() {
244 |
245 | const response = await fetch(`${this.auth.config.serverInfo.PublicAddress}/Packages/Installed/Playback%20Reporting?AssemblyGuid=5c53438191a343cb907a35aa02eb9d2c`, {
246 | method: `POST`,
247 | headers: {
248 | ...this.auth.config.defaultHeaders,
249 | },
250 | })
251 |
252 | if (response.status === 204) {
253 | return true
254 | } else {
255 | throw new Error(`Couldn't install Playback Reporting plugin!`, await response.text())
256 | }
257 |
258 | }
259 |
260 | // requires administrator account
261 | async enablePlaybackReportingPlugin(setup) {
262 |
263 | const response = await fetch(`${this.auth.config.serverInfo.PublicAddress}/Plugins/${setup.id}/${setup.version}/Enable`, {
264 | method: `POST`,
265 | headers: {
266 | ...this.auth.config.defaultHeaders,
267 | },
268 | })
269 |
270 | if (response.status === 204) {
271 | return true
272 | } else {
273 | throw new Error(`Couldn't enable Playback Reporting plugin!`, await response.text())
274 | }
275 |
276 | }
277 |
278 | // requires administrator account
279 | async updatePlaybackReportingSettings(settings) {
280 |
281 | const response = await fetch(`${this.auth.config.serverInfo.PublicAddress}/System/Configuration/playback_reporting`, {
282 | method: `POST`,
283 | headers: {
284 | ...this.auth.config.defaultHeaders,
285 | 'Content-Type': `application/json`,
286 | },
287 | body: JSON.stringify(settings),
288 | })
289 | return response.status === 204
290 | }
291 |
292 | // requires admin permissions
293 | async fetchDevices() {
294 | const response = await fetch(`${this.auth.config.serverInfo.PublicAddress}/Devices`, {
295 | method: `GET`,
296 | headers: {
297 | ...this.auth.config.defaultHeaders,
298 | 'Content-Type': `application/json`,
299 | },
300 | })
301 | return await response.json()
302 | }
303 |
304 | // requires admin permissions
305 | async shutdownServer() {
306 |
307 | const response = await fetch(`${this.auth.config.serverInfo.PublicAddress}/System/Shutdown`, {
308 | method: `POST`,
309 | headers: {
310 | ...this.auth.config.defaultHeaders,
311 | },
312 | })
313 | return response.ok
314 | }
315 |
316 | // requires admin permissions
317 | async restartServer() {
318 |
319 | const response = await fetch(`${this.auth.config.serverInfo.PublicAddress}/System/Restart`, {
320 | method: `POST`,
321 | headers: {
322 | ...this.auth.config.defaultHeaders,
323 | },
324 | })
325 | return response.ok
326 | }
327 |
328 | }
329 |
330 | function blurhashToDataURI(blurhash) {
331 | const pixels = decodeBlurhash(blurhash, 256, 256)
332 | const ctx = document.createElement(`canvas`).getContext(`2d`)
333 | const imageData = ctx.createImageData(256, 256)
334 | imageData.data.set(pixels)
335 | ctx.putImageData(imageData, 0, 0)
336 | return ctx.canvas.toDataURL()
337 | }
338 |
--------------------------------------------------------------------------------
/src/offline-import.js:
--------------------------------------------------------------------------------
1 | import { loadItemInfo, loadItemInfoBatched } from "./rewind";
2 |
3 | export function importOfflinePlayback(fileHandle) {
4 | return new Promise((resolve, reject) => {
5 | const reader = new FileReader();
6 | reader.onload = async () => {
7 |
8 | console.log(`Parsing offline data`)
9 | const offlinePlaybackData = await parseOfflinePlaybackData(reader.result)
10 | console.log(`offlinePlaybackData:`, offlinePlaybackData)
11 |
12 | // fetch item data to get track durations
13 | const itemInfo = await loadItemInfoBatched(offlinePlaybackData.map(play => play.itemId))
14 | console.log(`itemInfo:`, itemInfo)
15 | if (itemInfo[`Items`]?.length > 0) {
16 | // create map of item ID and duration for reduced time complexity
17 | const itemDurations = itemInfo[`Items`].reduce((all, cur) => {
18 | all[cur[`Id`]] = !isNaN(Math.round(cur[`RunTimeTicks`] / 10000000)) ? Math.round(cur[`RunTimeTicks`] / 10000000) : 0
19 | return all
20 | }, {})
21 | // enrich offline plays with fetched track durations
22 | for (const index in offlinePlaybackData) {
23 | offlinePlaybackData[index].playDuration = itemDurations[offlinePlaybackData[index].itemId]
24 | }
25 | }
26 |
27 | resolve(offlinePlaybackData);
28 | };
29 | reader.onerror = () => {
30 | reader.abort();
31 | reject(new Error("Error loading offline data"));
32 | };
33 | reader.readAsText(fileHandle);
34 | });
35 | }
36 |
37 | async function parseOfflinePlaybackData(fileContent) {
38 |
39 | console.log(`raw offline data:`, fileContent)
40 |
41 | let content
42 | try {
43 | content = JSON.parse(fileContent)
44 | } catch (err) {
45 | content = null
46 | }
47 |
48 | if (!!content) {
49 | console.warn(`Regular JSON detected, unsupported`)
50 | }
51 |
52 | console.log(`fileContent.split(/\r\n|\r|\n/):`, fileContent.split(/\r\n|\r|\n/))
53 | const contentLines = fileContent.split(/\r\n|\r|\n/).filter(x => x.trim().length > 0)
54 | console.log(`contentLines:`, contentLines)
55 | // detect supported formats
56 | if (contentLines.length > 0) {
57 | try {
58 | JSON.parse(contentLines[0])
59 | console.info(`Detected Finamp offline playback data format, attempting to parse`)
60 | return await parseFinampOfflinePlaybackData(contentLines)
61 | } catch (err) {
62 | console.error(`Error while parsing Finamp offline playback data:`, err)
63 | }
64 |
65 | }
66 |
67 | }
68 |
69 | async function parseFinampOfflinePlaybackData(contentLines) {
70 |
71 | console.log(`contentLines:`, contentLines)
72 |
73 | const plays = []
74 | // parsed successfully, now parse all lines
75 | plays.push(
76 | ...contentLines.map(line => {
77 | try {
78 | const parsedLine = JSON.parse(line)
79 | return new Play({
80 | timestamp: new Date(Number(parsedLine[`timestamp`]) * 1000),
81 | itemId: parsedLine[`item_id`],
82 | title: parsedLine[`title`],
83 | artist: parsedLine[`artist`],
84 | album: parsedLine[`album`],
85 | userId: parsedLine[`user_id`],
86 | musicBrainzId: parsedLine[`track_mbid`],
87 | client: `Finamp`,
88 | //TODO add device information to exported offline plays file
89 | })
90 | } catch (err) {
91 | return null
92 | }
93 | }).filter(x => !!x)
94 | )
95 |
96 | return plays
97 |
98 | }
99 |
100 | const uploadOfflinePlaybackQuery = (offlinePlays, auth) => {
101 | return `
102 | INSERT INTO PlaybackActivity
103 | (DateCreated, UserId, ItemId, ItemType, ItemName, PlaybackMethod, ClientName, DeviceName, PlayDuration)
104 | VALUES
105 | ${offlinePlays.map(play =>
106 | `( '${play.timestamp.toISOString().slice(0, 19).replace(`T`, ` `)}.0000000', '${play.userId ?? auth.config.user.id}', '${play.itemId}', 'Audio', '${play.artist?.replaceAll?.(`'`, `''`)} - ${play.title?.replaceAll?.(`'`, `''`)} (${play.album?.replaceAll?.(`'`, `''`)})', 'OfflinePlay', '${play.client?.replaceAll?.(`'`, `''`)}', '${play.device.replaceAll(`'`, `''`)}', ${play.playDuration ?? 0})`
107 | ).join(`,`)}
108 | `
109 | }
110 | // GROUP BY ItemId -- don't group so that we can filter out wrong durations
111 | // LIMIT 200
112 |
113 | export async function uploadOfflinePlaybackBatched(offlinePlays, auth) {
114 |
115 | console.info(`Importing offline playback to server`)
116 |
117 | const batchSize = 100
118 | for (let batchIndex = 0; batchIndex < Math.ceil(offlinePlays.length / batchSize); batchIndex++) {
119 | console.info(`Importing batch`)
120 | await uploadOfflinePlayback(offlinePlays.slice(batchSize*batchIndex, batchSize*(batchIndex+1)), auth)
121 | }
122 |
123 | }
124 |
125 | async function uploadOfflinePlayback(offlinePlays, auth) {
126 |
127 | console.log(`offlinePlays:`, offlinePlays)
128 | const response = await fetch(`${auth.config.baseUrl}/user_usage_stats/submit_custom_query?stamp=${Date.now()}`, {
129 | method: 'POST',
130 | headers: {
131 | ...auth.config.defaultHeaders,
132 | 'Content-Type': 'application/json',
133 | },
134 | body: JSON.stringify({
135 | "CustomQueryString": uploadOfflinePlaybackQuery(offlinePlays, auth),
136 | })
137 | })
138 | const json = await response.json()
139 |
140 | if (json[`message`]?.toLowerCase?.()?.includes?.(`query executed`)) {
141 | console.info(`Successfully imported offline playback to server`)
142 | return json
143 | } else {
144 | console.error(`Error while importing offline playback to server:`, json[`message`])
145 | throw new Error(`Error while importing offline playback to server: ${json[`message`]}`)
146 | }
147 |
148 | }
149 |
150 | export async function checkIfOfflinePlaybackImportAvailable() {
151 | try {
152 | const devices = await window.helper.fetchDevices()
153 | const finampBetaMatch = devices[`Items`].find(device => {
154 | if (device[`AppName`] === `Finamp`) {
155 | const versionString = device[`AppVersion`]
156 | const versionRegex = /^(\d+)\.(\d+)\.(\d+)$/;
157 | const [, major, minor, patch] = versionString.match(versionRegex) || [];
158 | return Number(major) > 0 || Number(minor) >= 9
159 | }
160 | })
161 | return finampBetaMatch
162 | } catch (err) {
163 | console.error(`Error while checking if offline playback import is available:`, err)
164 | }
165 | return false
166 | }
167 |
168 | export class Play {
169 |
170 | constructor({
171 | timestamp,
172 | itemId,
173 | title,
174 | artist,
175 | album,
176 | userId,
177 | musicBrainzId,
178 | playDuration,
179 | client,
180 | device,
181 | }) {
182 | this.timestamp = timestamp
183 | this.itemId = itemId
184 | this.title = title
185 | this.artist = artist
186 | this.album = album
187 | this.userId = userId
188 | this.musicBrainzId = musicBrainzId
189 | this.playDuration = playDuration
190 | this.client = client
191 | this.device = device ?? `Unknown Device`
192 | }
193 |
194 | }
195 |
--------------------------------------------------------------------------------
/src/onboarding.js:
--------------------------------------------------------------------------------
1 | import { reactive, watch, html, } from '@arrow-js/core'
2 |
3 | import { connectToServer, generateRewindReport, initializeFeatureStory, loginViaAuthToken, loginViaPassword, restoreAndPrepareRewind, deleteRewind } from './setup';
4 | import { getFeatureDelta, importRewindReport } from './delta';
5 | import { checkIfOfflinePlaybackImportAvailable, importOfflinePlayback, Play, uploadOfflinePlaybackBatched } from './offline-import';
6 |
7 | export const state = reactive({
8 | currentView: `start`,
9 | views: {},
10 | server: {
11 | url: ``,
12 | users: [
13 | {name: `test`, id: `test`},
14 | {name: `test`, id: `test`},
15 | {name: `test`, id: `test`},
16 | {name: `test`, id: `test`}
17 | ],
18 | loginType: `password`,
19 | selectedUser: null,
20 | },
21 | rewindGenerating: false,
22 | rewindReport: null,
23 | oldReport: null,
24 | offlinePlayback: null,
25 | importingExistingReport: false,
26 | importingLastYearsReport: false,
27 | importingOfflinePlayback: false,
28 | staleReport: false,
29 | progress: 0,
30 | waitingForRestart: false,
31 | playbackReportingInspectionAttempts: 0,
32 | auth: null,
33 | error: null,
34 | playbackReportingInspectionResult: null,
35 | connectionHelpDialogOpen: false,
36 | playbackReportingDialogOpen: false,
37 | finampOfflineExportDialogOpen: false,
38 | featuresInitialized: false,
39 | darkMode: null,
40 | selectedAction: null,
41 | })
42 |
43 | export async function init(auth) {
44 |
45 | state.views = reactive({
46 | start: viewStart,
47 | placeholder: viewPlaceholder,
48 | server: viewServer,
49 | user: viewUser,
50 | login: viewLogin,
51 | playbackReportingIssues: viewPlaybackReportingIssues,
52 | importReportForViewing: viewImportReportForViewing,
53 | importLastYearsReport: viewImportLastYearsReport,
54 | launchExistingReport: viewLaunchExistingReport,
55 | importOfflinePlayback: viewImportOfflinePlayback,
56 | load: viewLoad,
57 | revisit: viewRevisit,
58 | rewindGenerationError: viewRewindGenerationError,
59 | })
60 |
61 | state.auth = auth
62 | state.server.url = state.auth?.config?.baseUrl
63 |
64 | // MediaQueryList
65 | const darkModePreference = window.matchMedia("(prefers-color-scheme: dark)");
66 |
67 | // recommended method for newer browsers: specify event-type as first argument
68 | darkModePreference.addEventListener(`change`, e => {
69 | if (e.matches) {
70 | state.darkMode = true
71 | } else {
72 | state.darkMode = false
73 | }
74 | });
75 |
76 | state.darkMode = darkModePreference.matches
77 |
78 | handleBackButton()
79 |
80 | try {
81 | let restored = await restoreAndPrepareRewind()
82 |
83 | state.rewindReport = restored.rewindReportData
84 | state.staleReport = restored.staleReport
85 | console.log(`state.rewindReport:`, state.rewindReport)
86 | console.log(`state.auth.config.user:`, state.auth.config.user)
87 | if (state.auth?.config?.user) {
88 | await checkPlaybackReportingSetup(`revisit`)
89 | } else {
90 | state.currentView = `revisit`
91 | }
92 | } catch (err) {
93 | if (state.auth?.config?.user) {
94 | // determine which view to show
95 | await checkPlaybackReportingSetup()
96 | } else {
97 | if (JSON.parse(import.meta.env.VITE_SHOW_PLACEHOLDER)) {
98 | state.currentView = `placeholder`
99 | } else {
100 | state.currentView = `start`
101 | }
102 | }
103 | }
104 |
105 | }
106 |
107 | function handleBackButton() {
108 |
109 | // add hash to url on view change
110 | watch(() => state.currentView, (view) => {
111 | let url = new URL(location.href)
112 | url.hash = view
113 | history.pushState(null, null, url);
114 |
115 | })
116 |
117 | // handle back button by changing state.currentView
118 | // history.pushState(null, null, location.href);
119 | window.onpopstate = () => {
120 | // console.log(`back`)
121 | // history.go(1);
122 | state.currentView = document.location.hash.slice(1)
123 | };
124 | }
125 |
126 | export function render() {
127 | const onboardingElement = document.querySelector(`#onboarding`)
128 |
129 | html`
130 |
131 | ${() => state.views[state.currentView]}
132 | ${() => state.connectionHelpDialogOpen ? connectionHelpDialog : null}
133 | ${() => state.playbackReportingDialogOpen ? playbackReportingDialog : null}
134 | ${() => state.finampOfflineExportDialogOpen ? finampOfflineExportDialog : null}
135 |
136 | `(onboardingElement)
137 | }
138 |
139 | watch(() => [
140 | console.log(`state.currentView:`, state.currentView)
141 | ])
142 |
143 | const header = html`
144 |
145 |
146 |
147 | `
148 |
149 | const viewStart = html`
150 |
151 |
152 | ${() => header}
153 |
154 |
155 |
156 |
Hi there!
157 |
158 |
Before we can get started with your rewind, you'll have to log into your Jellyfin server.
159 |
Ideally, your server is reachable over the internet and via secure HTTPS, but even if not, there are ways to enjoy your Rewind.
160 |
Let's get started!
161 |
162 |
163 |
164 |
{
167 | if ((state.auth?.config?.baseUrl?.length ?? 0) > 0) {
168 | connect(state.auth?.config?.baseUrl)
169 | }
170 |
171 | state.currentView = `server`
172 | }}"
173 | >
174 | Log In
175 |
176 |
177 |
178 |
179 |
180 |
181 |
state.currentView = `importReportForViewing`}"
184 | >
185 | View an Existing Report Instead
186 |
187 |
188 |
189 | `
190 |
191 | const viewPlaceholder = html`
192 |
193 |
194 | ${() => header}
195 |
196 |
197 |
198 |
Hi there!
199 |
200 |
If you're looking for this year's Jellyfin Rewind, you'll have to wait a little longer. Jellyfin Rewind 2024 will launch on December 31st, 2024 (if all goes well).
201 |
In order to prepare for the launch, make sure your Playback Reporting plugin is installed and set up properly.
202 |
state.playbackReportingDialogOpen = true}">Click here to configure it!
203 |
204 |
If you want to review your stats from last year, you can now import your old report for viewing. The media itself probably won't load, but everything else should be there!
205 |
206 |
207 |
208 |
state.currentView = `importReportForViewing`}"
211 | >
212 | View An Old Report
213 |
214 |
215 |
216 | `
217 |
218 | const connectionHelpDialog = html`
219 |
220 |
state.connectionHelpDialogOpen = false}" class="absolute top-0 left-0 w-full h-full bg-black/20">
221 |
222 |
223 |
How to Connect?
224 |
state.connectionHelpDialogOpen = false}" class="absolute right-2 text-[#00A4DC] hover:text-[#0085B2]">
225 |
226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 |
Because Jellyfin Rewind is web-based and (for now at least) not available as a plugin, it might be a bit tricky to get your browser to communicate with your Jellyfin server. The problem is that browsers won't allow "insecure" requests (HTTP) from a "secure" website (HTTPS ), or requests from a non-private context (website not within your network) to a private context (Jellyfin server accessed over a local IP address within your network).
236 | ${() => window.location.protocol === `https:` ? html`
237 |
Make sure that your Jellyfin server is accessible via HTTPS . If your server is only available via regular HTTP, try going to http://jellyfin-rewind-http.chaphasilor.xyz
238 | ` : null}
239 |
So make sure you're not using a local IP address (starts with 192.168. ) or mDNS hostname (something like jellyfin.local ). If you use something like Tailscale as your VPN, you could use your server's Tailscale IP address.
240 |
Therefore, if you're unsure what your Jellyfin server is using, but your Jellyfin server is accessible over the internet, simply use the first link (http)!
241 |
If your server is NOT accessible over the internet, you could self-host the Jellyfin Rewind website on your local network, for example on the same server that is running Jellyfin. For that, check out the GitHub releases page and either download the zip-archive or use the provided Docker image. The zip-archive will need to be extracted into a folder that is served by a web server, like Apache or Nginx. The Docker image will need a to have port 80 exposed instead.
242 |
243 |
244 |
245 |
246 |
247 | `
248 |
249 | const playbackReportingDialog = html`
250 |
251 |
state.playbackReportingDialogOpen = false}" class="absolute top-0 left-0 w-full h-full bg-black/20">
252 |
253 |
254 |
Setting up Playback Reporting
255 |
state.playbackReportingDialogOpen = false}" class="absolute right-2 text-[#00A4DC] hover:text-[#0085B2]">
256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
264 |
265 |
Jellyfin doesn't save any information about played tracks other than the number of times they were played. This means that things like the total playtime are only an approximation. It also means that it is not possible to limit the data to a specific time frame, like ${import.meta.env.VITE_TARGET_YEAR} only!
266 |
However, if you have the "Playback Reporting" plugin installed, significantly more information can be collected, such as the date and durations of each playback. This results in better stats, although it isn't perfect either. Playback reporting depends on applications properly reporting the current playback states, and currently most music players that are compatible with Jellyfin seem to struggle with this in one way or another. Especially offline playback is challenging, because the players have to "simulate" the playback after the device reconnects to the server.
267 |
Still, the best solution is to install the Playback Reporting plugin into your Jellyfin server if you haven't done so already. It won't take longer than 2 minutes, so why not do it right now? (You'll have to be logged in as an admin user.)
268 | ${() => state.server.url !== `` ? html`
269 |
Open Plugins Page!
270 | ` : html`
271 |
{
274 | state.selectedAction = `openPluginsPage`
275 | state.currentView = `server`
276 | state.playbackReportingDialogOpen = false
277 | }}">Open Plugins Page!
278 | `
279 | }
280 |
By default, the Playback Reporting plugin only stores the last 3 months worth of playback data, so you definitely want to change that in the settings. I'd suggest keeping at least the last two years, just to be safe. The button below will take you directly to the settings page.
281 | ${() => state.server.url !== `` ? html`
282 |
Open Playback Reporting Settings!
283 | ` : html`
284 |
{
287 | state.selectedAction = `openPlaybackReportingSettings`
288 | state.currentView = `server`
289 | state.playbackReportingDialogOpen = false
290 | }}">Open Playback Reporting Settings!
291 | `
292 | }
293 |
For more information about the Playback Reporting plugin, you can visit its entry in the official Jellyfin Docs .
294 |
I will try to again offer a way to import ${() => state.rewindReport?.year}'s Rewind data into Jellyfin Rewind ${() => state.rewindReport?.year+1}, so that more information can be used and the used data can be properly limited to the current year only.
295 |
296 |
297 |
298 |
299 |
300 | `
301 | const finampOfflineExportDialog = html`
302 |
303 |
state.finampOfflineExportDialogOpen = false}" class="absolute top-0 left-0 w-full h-full bg-black/20">
304 |
305 |
306 |
Importing Offline Plays from Finamp (Beta)
307 |
state.finampOfflineExportDialogOpen = false}" class="absolute right-2 text-[#00A4DC] hover:text-[#0085B2]">
308 |
309 |
310 |
311 |
312 |
313 |
314 |
315 |
316 |
317 |
If you're using Finamp's beta version, you can easily export your offline plays to a file, and then import that file here into Jellyfin Rewind. Just follow these steps:
318 |
319 | 1. Open Finamp
320 | 2. Open the side menu / drawer
321 | 3. Go to the "Playback History" screen
322 | 4. Click the "Share" icon at the top right
323 | 5. Save the file, for example by sharing it to a file manager or sending it to yourself via email
324 |
325 |
Once you're ready, click the button below, and then click the "Import Offline Playback History" button and select the file you just exported
326 |
{
329 | state.finampOfflineExportDialogOpen = false
330 | }}">Close help dialog
331 |
I'll try to make this less complicated next year...
332 |
333 |
334 |
335 |
336 |
337 | `
338 |
339 | async function connect(url) {
340 | state.error = null
341 | if (url) {
342 | state.server.url = url
343 | } else {
344 | state.server.url = document.querySelector(`#onboarding-server-url`).value
345 | }
346 | // remove trailing slash
347 | state.server.url = state.server.url.replace(/\/$/, ``)
348 | try {
349 | state.server.users = await connectToServer(state.auth, state.server.url)
350 | state.currentView = `user`
351 | } catch (err) {
352 |
353 | console.error(`Error while connecting to the server:`, err)
354 | state.error = html`
355 |
356 |
There was an error while connecting to the server.
357 |
${err.toString()}
358 |
state.connectionHelpDialogOpen = true}">Help me!?
359 |
360 | `
361 |
362 | // try anyway, in case of CORS issues but correct URL
363 | if (state.selectedAction === `openPluginsPage`) {
364 | window.open(`${state.auth.config.baseUrl}/web/index.html#!/addplugin.html?name=Playback%20Reporting&guid=5c53438191a343cb907a35aa02eb9d2c`, `_blank`)
365 | } else if (state.selectedAction === `openPlaybackReportingSettings`) {
366 | window.open(`${state.auth.config.baseUrl}/web/index.html#!/configurationpage?name=playback_report_settings`, `_blank`)
367 | }
368 |
369 | }
370 | }
371 |
372 | const viewServer = html`
373 |
374 |
375 | ${() => header}
376 |
377 |
378 |
Type in the web address (URL) of your Jellyfin server in the field below.
379 |
If you don't know the URL, you can open your Jellyfin app, open the menu/sidebar and click on "Select Server". It should display your server's URL and you can easily copy it!
380 |
381 |
382 |
395 |
396 |
connect()}"
399 | >
400 | Connect
401 |
402 |
403 |
404 |
405 |
406 |
407 | ${() => state.error}
408 |
409 |
410 | `
411 |
412 | const viewUser = html`
413 |
414 |
415 | ${() => header}
416 |
417 |
418 |
That worked, amazing!
419 |
Now, select the user account you want to see the Rewind for.
420 |
421 |
422 |
423 |
424 |
425 |
Select a User
426 |
427 |
428 | ${() => state.server.users.map(user => html`
429 |
{
432 | state.server.selectedUser = user
433 | state.server.loginType = `password`
434 | state.currentView = `login`
435 | }}"
436 | >
437 |
440 | ${user.Name}
441 |
442 | `)}
443 |
444 | or
445 |
446 |
{
449 | state.server.loginType = `full`
450 | state.currentView = `login`
451 | }}"
452 | >
453 | Manually enter username
454 |
455 | or
456 | {
459 | state.server.loginType = `token`
460 | state.currentView = `login`
461 | }}"
462 | >
463 | Log in with a token
464 |
465 |
466 |
467 |
468 |
469 | ${() => buttonLogOut}
470 |
471 |
472 | `
473 |
474 | async function login() {
475 | const username = document.querySelector(`#onboarding-username`).value
476 | const password = document.querySelector(`#onboarding-password`).value
477 | try {
478 | let userInfo = await loginViaPassword(state.auth, username, password)
479 | // state.currentView = `importLastYearsReport`
480 | checkPlaybackReportingSetup()
481 | } catch (err) {
482 | console.error(`Error while logging in:`, err)
483 | state.error = html`
484 |
485 |
${err.toString()}
486 |
487 | `
488 | }
489 | }
490 |
491 | async function loginAuthToken() {
492 | const token = document.querySelector(`#onboarding-auth-token`).value
493 | try {
494 | let userInfo = await loginViaAuthToken(state.auth, token)
495 | // state.currentView = `importLastYearsReport`
496 | checkPlaybackReportingSetup()
497 | } catch (err) {
498 | console.error(`Error while logging in:`, err)
499 | state.error = html`
500 |
501 |
${err.toString()}
502 |
503 | `
504 | }
505 | }
506 |
507 | function inspectPlaybackReportingSetup(playbackReportingSetup, nextScreen) {
508 |
509 | const inspection = {
510 | valid: true,
511 | issue: ``,
512 | action: null,
513 | }
514 |
515 | if (!playbackReportingSetup.installed) {
516 | inspection.valid = false
517 | inspection.issue = `The Playback Reporting plugin is not installed.`
518 | if (window.helper && state.auth.config.user.isAdmin) {
519 | inspection.action = html`
520 | {
523 | try {
524 | await window.helper.installPlaybackReportingPlugin()
525 | checkPlaybackReportingSetup(nextScreen)
526 | } catch (err) {
527 | console.error(`Couldn't set up Playback Reporting Plugin!:`, err)
528 | }
529 | }}">Install Playback Reporting Plugin!
530 | `
531 | }
532 | } else if (playbackReportingSetup.restartRequired) {
533 | inspection.valid = false
534 | inspection.issue = `The Playback Reporting plugin is installed, but the Jellyfin server needs to be restarted in order to activate it.`
535 | state.waitingForRestart = false
536 | if (window.helper && state.auth.config.user.isAdmin) {
537 | inspection.action = html`
538 | {
541 | try {
542 | await window.helper.restartServer()
543 | state.waitingForRestart = true
544 | setTimeout(() => {
545 | checkPlaybackReportingSetup(nextScreen)
546 | }, 8000)
547 | } catch (err) {
548 | console.error(`Couldn't set up Playback Reporting Plugin!:`, err)
549 | }
550 | }}">${() => state.waitingForRestart ? `Server is restarting...` : `Restart Jellyfin Server`}
551 | `
552 | }
553 | } else if (playbackReportingSetup.disabled) {
554 | inspection.valid = false
555 | inspection.issue = `The Playback Reporting plugin is installed, but disabled.`
556 | if (window.helper && state.auth.config.user.isAdmin) {
557 | inspection.action = html`
558 | {
561 | try {
562 | await window.helper.enablePlaybackReportingPlugin(playbackReportingSetup)
563 | checkPlaybackReportingSetup(nextScreen)
564 | } catch (err) {
565 | console.error(`Couldn't set up Playback Reporting Plugin!:`, err)
566 | }
567 | }}">Enable Playback Reporting Plugin!
568 | `
569 | }
570 | } else if (playbackReportingSetup.version && parseInt(playbackReportingSetup.version) < 13) {
571 | inspection.valid = false
572 | inspection.issue = `The Playback Reporting plugin is installed and active, but there is a newer version available. Please update the plugin to the latest version to make sure everything is working correctly.`
573 | } else if (Number(playbackReportingSetup.settings.retentionInterval) !== -1 && Number(playbackReportingSetup.settings?.retentionInterval) < 24) {
574 | inspection.valid = false
575 | inspection.issue = `The Playback Reporting plugin is installed, but the retention interval is short (${playbackReportingSetup.settings?.retentionInterval} months).`
576 | if (window.helper && state.auth.config.user.isAdmin) {
577 | inspection.action = html`
578 | {
581 | try {
582 | const newSettings = {
583 | ...playbackReportingSetup.settings,
584 | MaxDataAge: "-1",
585 | }
586 | await window.helper.updatePlaybackReportingSettings(newSettings)
587 | checkPlaybackReportingSetup(nextScreen)
588 | } catch (err) {
589 | console.error(`Couldn't set up Playback Reporting Plugin!:`, err)
590 | }
591 | }}">Set retention interval to forever
592 | {
595 | try {
596 | const newSettings = {
597 | ...playbackReportingSetup.settings,
598 | MaxDataAge: 24,
599 | }
600 | await window.helper.updatePlaybackReportingSettings(newSettings)
601 | checkPlaybackReportingSetup(nextScreen)
602 | } catch (err) {
603 | console.error(`Couldn't set up Playback Reporting Plugin!:`, err)
604 | }
605 | }}">Set retention interval to 2 years
606 | `
607 | }
608 | } else if (playbackReportingSetup.ignoredUsers.some(user => user.id === state.auth?.config?.user?.id)) {
609 | inspection.valid = false
610 | inspection.issue = `The Playback Reporting plugin is installed and active, but your user account is ignored. Please remove your user from the list of ignored users so that listening activity is recorded for your account.`
611 | // if (window.helper && state.auth.config.user.isAdmin) {
612 | inspection.action = html`
613 | Open Playback Reporting Settings!
614 | checkPlaybackReportingSetup(nextScreen)}">Check Again
615 | `
616 | // }
617 | }
618 |
619 | return inspection
620 |
621 | }
622 |
623 | async function checkPlaybackReportingSetup(nextScreen) {
624 |
625 | // there's nothing we can do without the helper
626 | if (!window.helper) {
627 | state.currentView = nextScreen ?? `importLastYearsReport`
628 | return
629 | }
630 |
631 | if (!state.auth?.config?.user?.isAdmin) {
632 | state.currentView = nextScreen ?? `importLastYearsReport`
633 | return
634 | }
635 |
636 | // check if eligible for offline playback import
637 | if (await checkIfOfflinePlaybackImportAvailable(state.auth) && nextScreen !== `revisit`) {
638 | state.currentView = `importOfflinePlayback`
639 | return
640 | }
641 |
642 | nextScreen = nextScreen ?? `importLastYearsReport`
643 |
644 | try {
645 |
646 | const playbackReportingSetup = await window.helper.checkIfPlaybackReportingInstalled()
647 | console.log(`playbackReportingSetup:`, playbackReportingSetup)
648 |
649 | const inspection = inspectPlaybackReportingSetup(playbackReportingSetup, nextScreen)
650 | console.log(`inspection:`, inspection)
651 |
652 | state.waitingForRestart = false
653 |
654 | if (!inspection.valid) {
655 | state.currentView = `playbackReportingIssues`
656 | state.playbackReportingInspectionResult = inspection
657 | } else {
658 | state.currentView = nextScreen
659 | }
660 |
661 | } catch (err) {
662 | console.error(`Failed to check the playback reporting setup, continuing without it:`, err)
663 | state.playbackReportingInspectionAttempts++
664 | if (state.playbackReportingInspectionAttempts > 3) {
665 | state.currentView = nextScreen
666 | } else {
667 | setTimeout(() => {
668 | checkPlaybackReportingSetup(nextScreen)
669 | }, 5000)
670 | }
671 | }
672 |
673 | }
674 |
675 | const viewPlaybackReportingIssues = html`
676 |
677 |
678 | ${() => header}
679 |
680 |
681 |
It seems like the Playback Reporting plugin isn't set up correctly.
682 |
The following problem was detected:
683 |
684 |
685 |
686 |
${() => state.playbackReportingInspectionResult?.issue}
687 | ${() => state.playbackReportingInspectionResult?.action}
688 |
689 |
690 |
state.playbackReportingDialogOpen = true}"
693 | >
694 | More Information about why this is important
695 |
696 |
697 |
state.currentView = `importLastYearsReport`}"
700 | >
701 | Continue anyway
702 |
703 |
704 |
705 | `
706 |
707 | const viewLogin = html`
708 |
709 |
710 | ${() => header}
711 |
712 |
713 |
Almost there!
714 |
Please enter your credentials to log into your server. Your password will only be sent to your server.
715 |
716 |
717 |
758 |
759 |
[`password`, `full`].includes(state.server.loginType) ? login() : loginAuthToken()}"
762 | >
763 | Log In
764 |
765 |
766 |
767 |
768 |
769 |
770 | ${() => state.error}
771 |
772 |
773 | `
774 |
775 | const viewImportReportForViewing = html`
776 |
777 |
778 | ${() => header}
779 |
780 |
781 |
Have an existing Jellyfin Rewind report?
782 |
You can import it to take another look!
783 |
784 |
785 |
786 | ${() =>
787 | html`
788 |
Import Report
789 |
{
790 | console.info(`Importing file...`)
791 | const input = e.target
792 | try {
793 | state.importingExistingReport = true
794 | input.disabled = true
795 | state.rewindReport = await importRewindReport(e.target.files[0])
796 | state.auth.config.serverInfo = state.rewindReport.jellyfinRewindReport.server
797 | console.log(`state.auth.serverInfo:`, state.auth.serverInfo)
798 | console.log(`state.rewindReport:`, state.rewindReport)
799 | state.currentView = `launchExistingReport`
800 | // state.currentView = `load`
801 | // const featureDelta = await getFeatureDelta(oldReport, state.rewindReport)
802 | // console.log(`featureDelta:`, featureDelta)
803 | } catch (err) {
804 | console.error(`Error while importing rewind report:`, err)
805 | }
806 | input.disabled = false
807 | state.importingExistingReport = false
808 | }}">
809 |
810 | ${() => state.importingExistingReport ? html`
811 |
Importing, please wait a few seconds...
812 | ` : html`
813 |
state.currentView = `start`}"
816 | >
817 | Connect to a server instead
818 |
819 | `
820 | }
821 | `
822 | }
823 |
824 |
825 |
826 | `
827 |
828 | const viewLaunchExistingReport = html`
829 |
830 |
831 | ${() => header}
832 |
833 |
834 |
Your Rewind Report has been imported and is ready to view!
835 |
836 |
837 |
launchRewind()}"
840 | disabled="${() => !state.rewindReport || state.rewindGenerating}"
841 | >
842 | ${() => state.rewindGenerating ? `Generating...` : `Launch Rewind!`}
843 | ${() => !state.rewindGenerating ? html`
844 |
845 |
846 |
847 |
848 |
849 |
850 | ` : null}
851 |
852 |
853 |
854 | `
855 |
856 | const viewImportOfflinePlayback = html`
857 |
858 |
859 | ${() => header}
860 |
861 |
862 |
We noticed you've been using Finamp's beta version to listen to music.
863 |
Finamp keeps track of your playback history even when you're not connected to your server, and you can now import that history!
864 |
Imported plays will only be added to the Playback Reporting addon's database, but can then used to generate a more accurate Rewind report for you in the following steps.
865 |
Make sure to only import this once!
866 |
867 |
868 |
state.finampOfflineExportDialogOpen = true}"
871 | >
872 | How can I import my offline plays?
873 |
874 |
875 |
876 |
Import Offline Playback History
877 |
{
878 | console.info(`Importing offline playback...`)
879 | const input = e.target
880 | try {
881 | state.importingOfflinePlayback = true
882 | input.disabled = true
883 | state.offlinePlayback = await importOfflinePlayback(e.target.files[0])
884 | console.log(`state.offlinePlayback:`, state.offlinePlayback)
885 | console.log(`missing playDurations:`, state.offlinePlayback.filter(x => !x.playDuration))
886 |
887 | // import plays to server
888 | await uploadOfflinePlaybackBatched(state.offlinePlayback, state.auth)
889 |
890 | state.currentView = `importLastYearsReport`
891 | } catch (err) {
892 | console.error(`Error while importing offline playback data:`, err)
893 | }
894 | input.disabled = false
895 | state.importingOfflinePlayback = false
896 | }}">
897 |
898 | ${() => state.importingOfflinePlayback ? html`
899 |
Importing, please wait a few seconds...
900 | ` : html`
901 |
Already imported your offline plays or don't have any?
902 |
state.currentView = `importLastYearsReport`}"
905 | >
906 | Continue without importing
907 |
908 | `
909 | }
910 |
911 |
912 | ${() => buttonLogOut}
913 |
914 |
915 | `
916 |
917 | const viewImportLastYearsReport = html`
918 |
919 |
920 | ${() => header}
921 |
922 |
923 |
You can now import last year's Jellyfin Rewind report, if you have one.
924 |
This will give you more, and more reliable, statistics about your listening activity.
925 |
926 |
927 |
928 | ${() =>
929 | !state.oldReport ? html`
930 |
Import Last Year's Report
931 |
{
932 | console.info(`Importing file...`)
933 | const input = e.target
934 | try {
935 | state.importingLastYearsReport = true
936 | input.disabled = true
937 | state.oldReport = await importRewindReport(e.target.files[0])
938 | console.log(`state.oldReport:`, state.oldReport)
939 | state.currentView = `load`
940 | // const featureDelta = await getFeatureDelta(oldReport, state.rewindReport)
941 | // console.log(`featureDelta:`, featureDelta)
942 | } catch (err) {
943 | console.error(`Error while importing rewind report:`, err)
944 | }
945 | input.disabled = false
946 | state.importingLastYearsReport = false
947 | }}">
948 |
949 | ${() => state.importingLastYearsReport ? html`
950 |
Importing, please wait a few seconds...
951 | ` : html`
952 |
state.currentView = `load`}"
955 | >
956 | Continue without last year's report
957 |
958 |
959 | ${() => !state.auth.config.user.isAdmin ? html`
960 |
state.currentView = `importOfflinePlayback`}"
963 | >
964 | Using Finamp's beta? Import offline playback now!
965 |
966 | ` : null}
967 |
968 | `
969 | }
970 | ` : html`
971 |
state.currentView = `load`}"
974 | >
975 | Generate Rewind Report!
976 |
982 |
983 | `
984 | }
985 |
986 |
987 | ${() => buttonLogOut}
988 |
989 |
990 | `
991 |
992 | const progressBar = html`
993 |
994 |
995 |
1001 |
1002 | `
1003 |
1004 | function smoothSeek(current, target, duration) {
1005 | const diff = Math.abs(target - current)
1006 | const stepCount = 20
1007 | const step = Math.abs(diff / stepCount)
1008 |
1009 | const increase = () => {
1010 | if (state.progress < target) {
1011 | state.progress += step
1012 | setTimeout(increase, duration / stepCount)
1013 | }
1014 | }
1015 | increase()
1016 | }
1017 |
1018 | async function generateReport() {
1019 | try {
1020 | state.rewindGenerating = true
1021 | state.progress = 0
1022 | state.rewindReport = await generateRewindReport({
1023 | progressCallback: (progress) => {
1024 | smoothSeek(state.progress, progress, 750)
1025 | // state.progress = progress
1026 | },
1027 | oldReport: state.oldReport,
1028 | })
1029 | state.rewindGenerating = false
1030 | } catch (err) {
1031 | console.error(err)
1032 | state.rewindGenerating = false
1033 | state.currentView = `rewindGenerationError`
1034 | }
1035 | }
1036 |
1037 | watch(() => state.currentView, async (view) => {
1038 | if (view === `load`) {
1039 | await generateReport()
1040 | }
1041 | })
1042 |
1043 | function launchRewind() {
1044 | initializeFeatureStory(state.rewindReport, state.featuresInitialized)
1045 | state.featuresInitialized = true
1046 | state.currentView = `revisit`
1047 | }
1048 |
1049 | const viewLoad = html`
1050 |
1051 |
1052 | ${() => header}
1053 |
1054 |
1055 |
Your Rewind Report is now generating. This might take a few seconds.
1056 |
Please be patient, and if nothing happens for more than 30s, reach out to me via GitHub so that I can look into it :)
1057 |
1058 |
1059 | ${() => progressBar}
1060 |
1061 | ${() =>
1062 | state.rewindReport ? html`
1063 |
launchRewind()}"
1066 | disabled="${() => !state.rewindReport || state.rewindGenerating}"
1067 | >
1068 | ${() => state.rewindGenerating ? `Generating...` : `Launch Rewind!`}
1069 | ${() => !state.rewindGenerating ? html`
1070 |
1071 |
1072 |
1073 |
1074 |
1075 |
1076 | ` : null}
1077 |
1078 |
1079 | ` : html`
`
1080 | }
1081 |
1082 | ${() => buttonLogOut}
1083 |
1084 |
1085 | `
1086 |
1087 | const buttonLogOut = html`
1088 | {
1091 | await state.auth.destroySession()
1092 | await deleteRewind()
1093 | state.currentView = `start`
1094 | }}"
1095 | >
1096 | Log out
1097 |
1098 |
1099 |
1100 |
1101 |
1102 |
1103 | `
1104 |
1105 | const viewRevisit = html`
1106 |
1107 |
1108 | ${() => header}
1109 |
1110 |
1111 |
Welcome back, ${() => state.auth.config?.user?.name}!
1112 |
${() =>
1113 | !state.staleReport ?
1114 | html`
1115 | Your Rewind Report is still saved. You can view it any time you like.
1116 | ` : html`
1117 | The stored Rewind report is stale. Please re-generate it for the best experience.
1118 | `
1119 | }
1120 |
1121 |
1122 |
launchRewind()}"
1125 | disabled="${() => !state.rewindReport || state.rewindGenerating}"
1126 | >
1127 | Launch Rewind!
1128 |
1129 |
1130 |
1131 |
1132 |
1133 |
1134 |
1135 |
1136 |
{
1139 | checkPlaybackReportingSetup()
1140 | }}"
1141 | >
1142 | Regenerate Rewind
1143 |
1144 |
1145 |
1146 |
1147 |
1148 |
1149 |
1150 | ${() => buttonLogOut}
1151 |
1152 |
1153 | `
1154 |
1155 | const viewRewindGenerationError = html`
1156 |
1157 |
1158 | ${() => header}
1159 |
1160 |
1161 |
Sorry, we couldn't generate your Rewind Report.
1162 |
Please try again later.
1163 |
If you keep seeing this message, please reach out to me on GitHub or Reddit so that I can try to resolve the issue!
1164 |
1165 |
1166 |
{
1169 | state.currentView = `load`
1170 | }
1171 | }"
1172 | >
1173 | Try again
1174 |
1175 |
1176 |
1177 |
1178 |
1179 |
1180 |
1181 |
1182 | `
1183 |
--------------------------------------------------------------------------------
/src/rewind.js:
--------------------------------------------------------------------------------
1 | // import fetch from 'node-fetch'
2 |
3 | import Auth from './auth.js'
4 | import * as aggregate from './aggregate.js'
5 | import { getFeatureDelta } from './delta.js'
6 |
7 | let rewindReport = null
8 |
9 | const auth = new Auth()
10 |
11 | const playbackReportQuery = (year) => {
12 | return `
13 | SELECT ROWID, *
14 | FROM PlaybackActivity
15 | WHERE ItemType="Audio"
16 | AND datetime(DateCreated) >= datetime('${year}-01-01') AND datetime(DateCreated) <= datetime('${year}-12-31 23:59:59.9999999')
17 | AND UserId="${auth.config.user.id}"
18 | ORDER BY DateCreated ASC
19 | `
20 | }
21 | // GROUP BY ItemId -- don't group so that we can filter out wrong durations
22 | // LIMIT 200
23 |
24 | async function loadPlaybackReport(year) {
25 |
26 | const response = await fetch(`${auth.config.baseUrl}/user_usage_stats/submit_custom_query?stamp=${Date.now()}`, {
27 | method: 'POST',
28 | headers: {
29 | ...auth.config.defaultHeaders,
30 | 'Content-Type': 'application/json',
31 | },
32 | body: JSON.stringify({
33 | "CustomQueryString": playbackReportQuery(year),
34 | })
35 | })
36 | const json = await response.json()
37 | return json
38 |
39 | }
40 |
41 | function generateJSONFromPlaybackReport(playbackReportInfo) {
42 |
43 | if (!playbackReportInfo || !playbackReportInfo.results || !playbackReportInfo.colums) {
44 | return {
45 | items: [],
46 | }
47 | }
48 |
49 | const columns = playbackReportInfo.colums // intentional typo
50 | const items = []
51 | playbackReportInfo.results.forEach(x => {
52 | const item = {}
53 | columns.forEach((column, index) => {
54 | item[column] = x[index]
55 | })
56 | items.push(item)
57 | })
58 |
59 | return {
60 | items: items,
61 | }
62 |
63 | }
64 |
65 | function adjustPlaybackReportJSON(playbackReportJSON, indexedItemInfo) {
66 |
67 | playbackReportJSON.notFound = [] // deleted or otherwise not found items that should still contribute to the general stats
68 | for (const index in playbackReportJSON.items) {
69 | const item = playbackReportJSON.items[index] //!!! don't use this as a left-hand expression
70 |
71 | if (Number(item.PlayDuration) < 0) {
72 | console.error(`item duration:`, item)
73 | }
74 | const itemInfo = indexedItemInfo[item.ItemId]
75 | if (!itemInfo) {
76 | console.warn(`Item info not found: ${item.ItemId} (${item.ItemName})`)
77 | //TODO try to find a replacement item based on name and artist (case-insensitive, maybe even using a fuzzy score) (not album!)
78 | playbackReportJSON.notFound.push(item)
79 | continue
80 | }
81 |
82 | // adjust playback duration if necessary
83 |
84 | let playbackReportDuration = Number(item.PlayDuration)
85 | if (isNaN(playbackReportDuration) || playbackReportDuration < 0) {
86 | playbackReportDuration = 0
87 | }
88 | const jellyfinItemDuration = Math.ceil(itemInfo.RunTimeTicks / 10000000) // get duration in seconds
89 |
90 | playbackReportJSON.items[index].PlayDuration = playbackReportDuration
91 | if (playbackReportJSON.items[index].PlayDuration > jellyfinItemDuration + 1) {
92 | console.debug(`Wrong duration for ${item.ItemId} (${item.ItemName}), adjusting from ${playbackReportJSON.items[index].PlayDuration} to ${jellyfinItemDuration}`)
93 | playbackReportJSON.items[index].PlayDuration = jellyfinItemDuration
94 | playbackReportJSON.items[index].FullySkipped = false
95 | playbackReportJSON.items[index].PartiallySkipped = false
96 | } else if (playbackReportJSON.items[index].PlayDuration < (jellyfinItemDuration - 1) * 0.3) {
97 | playbackReportJSON.items[index].FullySkipped = true
98 | playbackReportJSON.items[index].PartiallySkipped = true
99 | } else if (playbackReportJSON.items[index].PlayDuration < (jellyfinItemDuration - 1) * 0.7) {
100 | playbackReportJSON.items[index].FullySkipped = false
101 | playbackReportJSON.items[index].PartiallySkipped = true
102 | }
103 |
104 | }
105 |
106 | return playbackReportJSON
107 |
108 | }
109 |
110 | function indexPlaybackReport(playbackReportJSON) {
111 |
112 | const convertPlaybackMethod = (playbackMethod) => {
113 | switch (playbackMethod) {
114 | case `DirectPlay`:
115 | return `directPlay`
116 | case `Transcode`:
117 | return `transcode`
118 | case `DirectStream`:
119 | return `directStream`
120 | default:
121 | return playbackMethod
122 | }
123 | }
124 |
125 | let currentSuccessivePlays = {
126 | count: 0,
127 | totalDuration: 0,
128 | itemId: null,
129 | }
130 |
131 | const items = {}
132 | for (const item of playbackReportJSON.items) {
133 | const isoDate = item.DateCreated.replace(` `, `T`) + `Z` // Safari doesn't seem to support parsing the raw dates from playback reporting (RFC 3339)
134 | const playInfo = {
135 | date: new Date(isoDate),
136 | duration: !isNaN(Number(item.PlayDuration)) ? Number(item.PlayDuration) : 0,
137 | wasFullSkip: item.FullySkipped,
138 | wasPartialSkip: item.PartiallySkipped && !item.FullySkipped,
139 | client: item.ClientName,
140 | device: item.DeviceName,
141 | method: convertPlaybackMethod(item.PlaybackMethod),
142 | }
143 | // if (playInfo.wasPartialSkip || playInfo.wasFullSkip) {
144 | // if (playInfo.wasFullSkip) {
145 | if (playInfo.wasPartialSkip) {
146 | console.warn(`SKIP`)
147 | }
148 | if (!items[item.ItemId]) {
149 | items[item.ItemId] = {
150 | ...item,
151 | TotalDuration: !isNaN(Number(item.PlayDuration)) ? Number(item.PlayDuration) : 0,
152 | TotalPlayCount: 1,
153 | Plays: [playInfo],
154 | FullSkips: playInfo.wasFullSkip ? 1 : 0,
155 | PartialSkips: playInfo.wasPartialSkip ? 1 : 0,
156 | }
157 |
158 | } else {
159 | items[item.ItemId].TotalDuration += !isNaN(Number(item.PlayDuration)) ? Number(item.PlayDuration) : 0
160 | items[item.ItemId].TotalPlayCount += 1
161 | if (playInfo.wasFullSkip) {
162 | items[item.ItemId].FullSkips += 1
163 | }
164 | if (playInfo.wasPartialSkip) {
165 | items[item.ItemId].PartialSkips += 1
166 | }
167 | items[item.ItemId].Plays.push(playInfo)
168 | }
169 |
170 | // calculate successive plays
171 | if (!currentSuccessivePlays.itemId || currentSuccessivePlays.itemId !== item.ItemId) {
172 | if (currentSuccessivePlays.itemId) {
173 | items[currentSuccessivePlays.itemId].MostSuccessivePlays = {
174 | playCount: currentSuccessivePlays.count,
175 | totalDuration: currentSuccessivePlays.totalDuration,
176 | }
177 | }
178 | currentSuccessivePlays.itemId = item.ItemId
179 | currentSuccessivePlays.count = 1
180 | currentSuccessivePlays.totalDuration = !isNaN(Number(item.PlayDuration)) ? Number(item.PlayDuration) : 0
181 | } else {
182 | currentSuccessivePlays.count += 1
183 | currentSuccessivePlays.totalDuration += !isNaN(Number(item.PlayDuration)) ? Number(item.PlayDuration) : 0
184 | }
185 |
186 | }
187 |
188 | return items
189 | }
190 |
191 | function indexArtists(artistInfoJSON) {
192 | const items = {}
193 | for (const item of artistInfoJSON.Items) {
194 | if (!items[item.Id]) {
195 | items[item.Id] = {
196 | ...item,
197 | }
198 |
199 | } else {
200 | }
201 |
202 | }
203 | return items
204 | }
205 |
206 | function indexAlbums(albumInfoJSON) {
207 | const items = {}
208 | for (const item of albumInfoJSON.Items) {
209 | if (!items[item.Id]) {
210 | items[item.Id] = {
211 | ...item,
212 | }
213 |
214 | } else {
215 | }
216 |
217 | }
218 | return items
219 | }
220 |
221 | export async function loadItemInfo(items) {
222 |
223 | const params = {
224 | // 'SortBy': `Album,SortName`,
225 | // 'SortOrder': `Ascending`,
226 | 'IncludeItemTypes': `Audio`,
227 | 'Recursive': `true`,
228 | 'Fields': `AudioInfo,ParentId,Ak,Genres`,
229 | 'EnableImageTypes': `Primary`,
230 | 'Ids': !!items ? [...new Set(items)].join(',') : undefined,
231 | }
232 |
233 | const queryParams = Object.entries(params).map(([key, value]) => `${key}=${value}`).join('&')
234 |
235 | const response = await fetch(`${auth.config.baseUrl}/Users/${auth.config.user.id}/Items?${queryParams}`, {
236 | method: 'GET',
237 | headers: {
238 | ...auth.config.defaultHeaders,
239 | 'Content-Type': 'application/json',
240 | },
241 | })
242 |
243 | const json = await response.json()
244 | return json
245 |
246 | }
247 |
248 | export async function loadItemInfoBatched(items) {
249 |
250 | let combinedResponse = null
251 | const batchSize = 200
252 | let response
253 | for (let batchIndex = 0; batchIndex < Math.ceil(items.length / batchSize); batchIndex++) {
254 | console.info(`Fetching item batch info`)
255 | response = await loadItemInfo(items.slice(batchSize*batchIndex, batchSize*(batchIndex+1)), auth)
256 | if (!combinedResponse) {
257 | combinedResponse = response
258 | } else {
259 | combinedResponse.Items.push(...response.Items)
260 | }
261 | console.log(`combinedResponse.Items.length:`, combinedResponse.Items.length)
262 | }
263 |
264 | return combinedResponse
265 |
266 | }
267 |
268 | async function loadArtistInfo(items) {
269 |
270 | const params = {
271 | // 'SortBy': `Album,SortName`,
272 | // 'SortOrder': `Ascending`,
273 | // 'IncludeItemTypes': `Audio`,
274 | 'Recursive': `true`,
275 | 'Fields': `PrimaryImageAspectRatio,SortName,BasicSyncInfo,Genres`,
276 | 'EnableImageTypes': `Primary,Backdrop,Banner,Thumb`,
277 | 'userId': auth.config.user.id,
278 | // 'Ids': items.map(item => item.id).join(','),
279 | }
280 |
281 | const queryParams = Object.entries(params).map(([key, value]) => `${key}=${value}`).join('&')
282 |
283 | let response = await fetch(`${auth.config.baseUrl}/Artists?${queryParams}`, {
284 | method: 'GET',
285 | headers: {
286 | ...auth.config.defaultHeaders,
287 | 'Content-Type': 'application/json',
288 | },
289 | })
290 |
291 | let artistResponse = await response.json()
292 |
293 | response = await fetch(`${auth.config.baseUrl}/Artists/AlbumArtists?${queryParams}`, {
294 | method: 'GET',
295 | headers: {
296 | ...auth.config.defaultHeaders,
297 | 'Content-Type': 'application/json',
298 | },
299 | })
300 |
301 | let albumArtistResponse = await response.json()
302 | artistResponse.Items = artistResponse.Items.concat(albumArtistResponse.Items)
303 | return artistResponse
304 |
305 | }
306 |
307 | async function loadAlbumInfo() {
308 |
309 | const params = {
310 | // 'SortBy': `Album,SortName`,
311 | // 'SortOrder': `Ascending`,
312 | 'IncludeItemTypes': `MusicAlbum`,
313 | 'Recursive': `true`,
314 | 'Fields': `PrimaryImageAspectRatio,SortName,BasicSyncInfo,Genres`,
315 | 'EnableImageTypes': `Primary,Backdrop,Banner,Thumb`,
316 | 'userId': auth.config.user.id,
317 | // 'Ids': items.map(item => item.id).join(','),
318 | }
319 |
320 | const queryParams = Object.entries(params).map(([key, value]) => `${key}=${value}`).join('&')
321 |
322 | let response = await fetch(`${auth.config.baseUrl}/Users/${auth.config.user.id}/Items?${queryParams}`, {
323 | method: 'GET',
324 | headers: {
325 | ...auth.config.defaultHeaders,
326 | 'Content-Type': 'application/json',
327 | },
328 | })
329 |
330 | let responseJSON = await response.json()
331 |
332 | return responseJSON
333 |
334 | }
335 |
336 | function indexItemInfo(itemInfo) {
337 | const items = {}
338 | itemInfo.forEach(item => {
339 | items[item.Id] = item
340 | })
341 | return items
342 | }
343 |
344 | function adjustItemInfo(itemInfo, oldReport) {
345 |
346 | console.log(`itemInfo:`, itemInfo)
347 |
348 | if (!oldReport) {
349 | return itemInfo
350 | }
351 |
352 | console.info(`Adjusting item info based on previous report...`)
353 |
354 | const oldTrackInfo = oldReport.rawData.allItemInfoIndexed
355 |
356 | for (const itemId of Object.keys(itemInfo)) {
357 | const oldItemInfo = oldTrackInfo[itemId]
358 | if (oldItemInfo) {
359 | let adjustedPlayCount
360 | if (itemInfo[itemId].UserData.PlayCount >= oldItemInfo.UserData.PlayCount) {
361 | adjustedPlayCount = itemInfo[itemId].UserData.PlayCount - oldItemInfo.UserData.PlayCount
362 | } else {
363 | adjustedPlayCount = itemInfo[itemId].UserData.PlayCount
364 | }
365 | if (itemInfo[itemId].UserData.PlayCount !== adjustedPlayCount) {
366 | // console.log(`itemId, originalPlayCount, adjustedPlayCount:`, itemId, itemInfo[itemId].UserData.PlayCount, adjustedPlayCount)
367 | }
368 | itemInfo[itemId].UserData = {
369 | ...itemInfo[itemId].UserData,
370 | PlayCount: adjustedPlayCount,
371 | }
372 | }
373 | }
374 |
375 | return itemInfo
376 |
377 | }
378 |
379 | function chunkedArray(array, chunkSize) {
380 | const chunks = []
381 | const originalLength = array.length
382 | for (let i = 0; i < originalLength; i += chunkSize) {
383 | chunks.push(array.splice(0, chunkSize))
384 | }
385 | return chunks
386 | }
387 |
388 | async function generateRewindReport({
389 | year,
390 | progressCallback,
391 | oldReport,
392 | }) {
393 |
394 | try {
395 |
396 | progressCallback = progressCallback || (() => {})
397 |
398 | console.info(`Generating Rewind Report for ${year}...`)
399 | progressCallback(0)
400 |
401 | let playbackReportAvailable = true
402 | let playbackReportComplete = true
403 | let playbackReportDataMissing = false
404 |
405 | let playbackReportInfo
406 | try {
407 | playbackReportInfo = await loadPlaybackReport(year)
408 | } catch (err) {
409 | console.warn(`Playback Reporting not available!`)
410 | playbackReportInfo = null
411 | playbackReportAvailable = false
412 | }
413 | progressCallback(0.2)
414 |
415 | const playbackReportJSON = generateJSONFromPlaybackReport(playbackReportInfo)
416 | console.log(`playbackReportJSON:`, playbackReportJSON)
417 | if (playbackReportJSON.items.length === 0) {
418 | playbackReportDataMissing = true
419 | }
420 | progressCallback(0.25)
421 |
422 | // const allItemInfo = []
423 |
424 |
425 | // for (const items of chunkedArray(Object.values(playbackReportJSON.items), 200)) {
426 | // const itemInfo = await loadItemInfo(items)
427 | // allItemInfo.push(...itemInfo.Items)
428 | // }
429 |
430 | const allItemInfo = (await loadItemInfo()).Items;
431 | progressCallback(0.3)
432 |
433 | console.log(`allItemInfo:`, allItemInfo)
434 |
435 | const allItemInfoIndexed = indexItemInfo(allItemInfo)
436 | progressCallback(0.4)
437 |
438 | const allItemInfoAdjusted = adjustItemInfo(allItemInfoIndexed, oldReport)
439 | progressCallback(0.45)
440 |
441 | const enhancedPlaybackReportJSON = adjustPlaybackReportJSON(playbackReportJSON, allItemInfoAdjusted)
442 | progressCallback(0.5)
443 | const indexedPlaybackReport = indexPlaybackReport(enhancedPlaybackReportJSON)
444 | console.log(`indexedPlaybackReport:`, indexedPlaybackReport)
445 | progressCallback(0.6)
446 |
447 | console.log(`Object.keys(allItemInfoAdjusted).length:`, Object.keys(allItemInfoAdjusted).length)
448 | const allTopTrackInfo = aggregate.generateTopTrackInfo(allItemInfoAdjusted, indexedPlaybackReport)
449 | progressCallback(0.7)
450 |
451 | const artistInfo = indexArtists(await loadArtistInfo())
452 | console.log(`artistInfo:`, artistInfo)
453 | progressCallback(0.75)
454 |
455 | const albumInfo = indexAlbums(await loadAlbumInfo())
456 | console.log(`albumInfo:`, albumInfo)
457 | progressCallback(0.8)
458 |
459 | const totalStats = aggregate.generateTotalStats(allTopTrackInfo, enhancedPlaybackReportJSON)
460 | progressCallback(0.95)
461 |
462 | const jellyfinRewindReport = {
463 | commit: __COMMITHASH__,
464 | year,
465 | timestamp: new Date().toISOString(),
466 | user: {
467 | id: auth.config?.user?.id,
468 | name: auth.config?.user?.name,
469 | },
470 | server: auth.config?.serverInfo,
471 | type: `full`,
472 | playbackReportAvailable,
473 | playbackReportDataMissing,
474 | generalStats: {},
475 | tracks: {},
476 | albums: {},
477 | artists: {},
478 | genres: {},
479 | }
480 |
481 | // check if at least 3 months of playback report data is available
482 | jellyfinRewindReport.generalStats[`totalPlaybackDurationByMonth`] = aggregate.generateTotalPlaybackDurationByMonth(indexedPlaybackReport)
483 | if (Object.values(jellyfinRewindReport.generalStats[`totalPlaybackDurationByMonth`]).filter(month => month > 0).length < 12) {
484 | playbackReportComplete = false
485 | }
486 | console.log(`jellyfinRewindReport.generalStats['totalPlaybackDurationByMonth']:`, jellyfinRewindReport.generalStats[`totalPlaybackDurationByMonth`])
487 |
488 | console.log(`playbackReportAvailable:`, playbackReportAvailable)
489 | console.log(`playbackReportComplete:`, playbackReportComplete)
490 | const dataSource = playbackReportAvailable ? (playbackReportComplete ? `playbackReport` : `average`) : `jellyfin`
491 |
492 | jellyfinRewindReport.generalStats[`totalPlays`] = {
493 | playbackReport: totalStats.totalPlayCount[`playbackReport`],
494 | average: totalStats.totalPlayCount[`average`],
495 | jellyfin: totalStats.totalPlayCount[`jellyfin`],
496 | }
497 | jellyfinRewindReport.generalStats[`totalPlaybackDurationMinutes`] = {
498 | playbackReport: Number((totalStats.totalPlayDuration[`playbackReport`]).toFixed(1)),
499 | average: Number((totalStats.totalPlayDuration[`average`]).toFixed(1)),
500 | jellyfin: Number((totalStats.totalPlayDuration[`jellyfin`]).toFixed(1)),
501 | }
502 | jellyfinRewindReport.generalStats[`totalPlaybackDurationHours`] = {
503 | playbackReport: Number((totalStats.totalPlayDuration[`playbackReport`] / 60).toFixed(1)),
504 | average: Number((totalStats.totalPlayDuration[`average`] / 60).toFixed(1)),
505 | jellyfin: Number((totalStats.totalPlayDuration[`jellyfin`] / 60).toFixed(1)),
506 | }
507 | jellyfinRewindReport.generalStats[`uniqueTracksPlayed`] = totalStats.uniqueTracks
508 | jellyfinRewindReport.generalStats[`uniqueAlbumsPlayed`] = totalStats.uniqueAlbums
509 | jellyfinRewindReport.generalStats[`uniqueArtistsPlayed`] = totalStats.uniqueArtists
510 |
511 | jellyfinRewindReport.generalStats[`playbackMethods`] = totalStats.playbackMethods
512 | jellyfinRewindReport.generalStats[`locations`] = totalStats.locations
513 |
514 | jellyfinRewindReport.generalStats[`mostSuccessivePlays`] = totalStats.mostSuccessivePlays
515 | jellyfinRewindReport.generalStats[`totalMusicDays`] = totalStats.totalMusicDays
516 | jellyfinRewindReport.generalStats[`minutesPerDay`] = totalStats.minutesPerDay
517 |
518 | jellyfinRewindReport.libraryStats = totalStats.libraryStats
519 |
520 | const topTracksByDuration = aggregate.getTopItems(allTopTrackInfo, { by: `duration`, limit: 20, dataSource: dataSource })
521 | const topTracksByPlayCount = aggregate.getTopItems(allTopTrackInfo, { by: `playCount`, limit: 20, dataSource: dataSource })
522 | const topTracksByLeastSkipped = aggregate.getTopItems(allTopTrackInfo, { by: `skips`, lowToHigh: true, limit: 20, dataSource: dataSource })
523 | const topTracksByMostSkipped = aggregate.getTopItems(allTopTrackInfo, { by: `skips.full`, limit: 20, dataSource: dataSource })
524 | // const topTracksByLastPlayed = aggregate.getTopItems(allTopTrackInfo, { by: `lastPlayed`, limit: 20, dataSource: dataSource })
525 |
526 | jellyfinRewindReport.tracks[`duration`] = topTracksByDuration
527 | // .map(x => `${x.name} by ${x.artistsBaseInfo[0].name}: ${Number((x.totalPlayDuration / 60).toFixed(1))} minutes`).join(`\n`)
528 | jellyfinRewindReport.tracks[`playCount`] = topTracksByPlayCount
529 | jellyfinRewindReport.tracks[`leastSkipped`] = topTracksByLeastSkipped
530 | jellyfinRewindReport.tracks[`mostSkipped`] = topTracksByMostSkipped
531 | // .map(x => `${x.name} by ${x.artistsBaseInfo[0].name}: ${x.playCount.average} plays`).join(`\n`)
532 | // jellyfinRewindReport.tracks[`topTracksByLastPlayed`] = topTracksByLastPlayed
533 | // .map(x => `${x.name} by ${x.artistsBaseInfo[0].name}: last played on ${x.lastPlayed}`).join(`\n`)
534 |
535 | const topAlbumInfo = aggregate.generateAlbumInfo(allTopTrackInfo, albumInfo)
536 | const topAlbumsByDuration = aggregate.getTopItems(topAlbumInfo, { by: `duration`, limit: 20, dataSource: dataSource })
537 | const topAlbumsByPlayCount = aggregate.getTopItems(topAlbumInfo, { by: `playCount`, limit: 20, dataSource: dataSource })
538 | // const topAlbumsByLastPlayed = aggregate.getTopItems(topAlbumInfo, { by: `lastPlayed`, limit: 20, dataSource: dataSource })
539 |
540 | jellyfinRewindReport.albums[`duration`] = topAlbumsByDuration
541 | // .map(x => `${x.name} by ${x.albumArtist.name}: ${Number((x.totalPlayDuration / 60).toFixed(1))} minutes`).join(`\n`)
542 | jellyfinRewindReport.albums[`playCount`] = topAlbumsByPlayCount
543 | // .map(x => `${x.name} by ${x.albumArtist.name}: ${x.playCount.average} plays`).join(`\n`)
544 | // jellyfinRewindReport.albums[`topAlbumsByLastPlayed`] = topAlbumsByLastPlayed
545 | // .map(x => `${x.name} by ${x.albumArtist.name}: last played on ${x.lastPlayed}`).join(`\n`)
546 |
547 | const topArtistInfo = aggregate.generateArtistInfo(allTopTrackInfo, artistInfo)
548 | const topArtistsByDuration = aggregate.getTopItems(topArtistInfo, { by: `duration`, limit: 20, dataSource: dataSource })
549 | const topArtistsByPlayCount = aggregate.getTopItems(topArtistInfo, { by: `playCount`, limit: 20, dataSource: dataSource })
550 | // const topArtistsByLastPlayed = aggregate.getTopItems(topArtistInfo, { by: `lastPlayed`, limit: 20, dataSource: dataSource })
551 |
552 | jellyfinRewindReport.artists[`duration`] = topArtistsByDuration
553 | // .map(x => `${x.name}: ${Number((x.totalPlayDuration / 60).toFixed(1))} minutes`).join(`\n`)
554 | jellyfinRewindReport.artists[`playCount`] = topArtistsByPlayCount
555 | // .map(x => `${x.name}: ${x.playCount.average} plays`).join(`\n`)
556 | // jellyfinRewindReport.artists[`topArtistsByLastPlayed`] = topArtistsByLastPlayed
557 | // .map(x => `${x.name}: last played on ${x.lastPlayed}`).join(`\n`)
558 |
559 | const topGenreInfo = aggregate.generateGenreInfo(allTopTrackInfo)
560 | const topGenresByDuration = aggregate.getTopItems(topGenreInfo, { by: `duration`, limit: 20, dataSource: dataSource })
561 | const topGenresByPlayCount = aggregate.getTopItems(topGenreInfo, { by: `playCount`, limit: 20, dataSource: dataSource })
562 | // const topGenresByLastPlayed = aggregate.getTopItems(topGenreInfo, { by: `lastPlayed`, limit: 20, dataSource: dataSource })
563 |
564 | jellyfinRewindReport.genres[`duration`] = topGenresByDuration
565 | // .map(x => `${x.name}: ${Number((x.totalPlayDuration / 60).toFixed(1))} minutes`).join(`\n`)
566 | jellyfinRewindReport.genres[`playCount`] = topGenresByPlayCount
567 | // .map(x => `${x.name}: ${x.playCount.average} plays`).join(`\n`)
568 | // jellyfinRewindReport.genres[`topGenresByLastPlayed`] = topGenresByLastPlayed
569 | // .map(x => `${x.name}: last played on ${x.lastPlayed}`).join(`\n`)
570 |
571 | jellyfinRewindReport.playbackReportComplete = playbackReportComplete
572 |
573 | if (oldReport) {
574 | const featureDelta = await getFeatureDelta(oldReport, { jellyfinRewindReport })
575 |
576 | jellyfinRewindReport.featureDelta = featureDelta
577 | }
578 |
579 | console.log(`jellyfinRewindReport:`, jellyfinRewindReport)
580 |
581 | progressCallback(1)
582 |
583 | rewindReport = jellyfinRewindReport
584 |
585 | return {
586 | jellyfinRewindReport,
587 | rawData: {
588 | allItemInfoIndexed: allItemInfoAdjusted,
589 | indexedPlaybackReport,
590 | allTopTrackInfo,
591 | totalStats,
592 | topArtistInfo,
593 | topAlbumInfo,
594 | topGenreInfo,
595 | },
596 | }
597 |
598 | } catch (err) {
599 | console.error(`Error while generating the Rewind report:`, err)
600 | throw err
601 | }
602 |
603 | }
604 |
605 | function saveRewindReport() {
606 | if (rewindReport !== null) {
607 | try {
608 | const rewindReportJSON = JSON.stringify(rewindReport)
609 | console.log(`Full report length:`, rewindReportJSON.length)
610 | localStorage.setItem(`rewindReport`, rewindReportJSON)
611 | } catch (err) {
612 | console.warn(`Couldn't save full report to localStorage (maybe quota exceeded?). Saving a subset only`)
613 | const reduceToSubsets = categoryEntry => Object.entries(categoryEntry).reduce((acc, [key, value]) => {
614 | console.log(`key:`, key)
615 | acc[key] = value.map(x => x.subsetOnly ? x.subsetOnly() : x)
616 | return acc
617 | }, {})
618 | const rewindReportLightJSON = JSON.stringify({
619 | ...rewindReport,
620 | type: `light`,
621 | tracks: reduceToSubsets(rewindReport.tracks),
622 | albums: reduceToSubsets(rewindReport.albums),
623 | artists: reduceToSubsets(rewindReport.artists),
624 | genres: reduceToSubsets(rewindReport.genres),
625 | })
626 | console.log(`Light report length:`, rewindReportLightJSON.length)
627 | localStorage.setItem(`rewindReportLight`, rewindReportLightJSON)
628 | }
629 | } else {
630 | console.warn(`No Rewind report to save!`)
631 | }
632 | }
633 |
634 | function restoreRewindReport() {
635 | const rewindReportJSON = localStorage.getItem(`rewindReport`)
636 | if (rewindReportJSON !== null) {
637 | rewindReport = JSON.parse(rewindReportJSON)
638 | } else {
639 | console.warn(`No full Rewind report to restore! Attempting to restore light report...`)
640 |
641 | const rewindReportLightJSON = localStorage.getItem(`rewindReportLight`)
642 | if (rewindReportLightJSON !== null) {
643 | rewindReport = JSON.parse(rewindReportLightJSON)
644 | } else {
645 | throw new Error(`No Rewind report to restore!`)
646 | }
647 | }
648 |
649 | if (rewindReport.commit !== __COMMITHASH__) {
650 | console.warn(`Rewind report was generated with a different version of the app!`)
651 | }
652 | if (rewindReport.year !== new Date().getFullYear()) {
653 | console.warn(`Rewind report was generated for a different year (${rewindReport.year})!`)
654 | }
655 |
656 | return rewindReport
657 | }
658 |
659 | function deleteRewindReport() {
660 | localStorage.removeItem(`rewindReport`)
661 | localStorage.removeItem(`rewindReportLight`)
662 | }
663 |
664 | export {
665 | generateRewindReport,
666 | saveRewindReport,
667 | restoreRewindReport,
668 | deleteRewindReport,
669 | auth,
670 | }
671 |
--------------------------------------------------------------------------------
/src/setup.js:
--------------------------------------------------------------------------------
1 | import * as Features from './features.js'
2 |
3 | export async function connectToServer(auth, serverUrl) {
4 | let userInfo
5 | try {
6 | await auth.connectToServer(serverUrl)
7 | userInfo = await auth.fetchUsers()
8 | console.info(`Connected to server!`)
9 | console.info(`Users:`, userInfo)
10 | } catch (err) {
11 | console.error(`Error while connecting to the server:`, err)
12 | throw new Error(`Error while connecting to the server. Make sure your using the same protocol (https or http) as your server is using. Make sure you're not using a local IP address or mDNS hostname. For example, you could use your server's Tailscale IP address, if you use Tailscale as your VPN. Original Error: ${err}`)
13 | }
14 | return userInfo
15 | }
16 |
17 | export async function loginViaPassword(auth, username, password) {
18 |
19 | await auth.authenticateUser(username, password)
20 | console.info(`Successfully logged in as ${username}`)
21 | auth.saveSession()
22 |
23 | return auth.config.user
24 |
25 | }
26 |
27 | export async function loginViaAuthToken(auth, authToken) {
28 |
29 | await auth.authenticateUserViaToken(authToken)
30 | console.info(`Successfully logged in as ${auth.config.name}`)
31 | auth.saveSession()
32 |
33 | return auth.config.user
34 |
35 | }
36 |
37 | export function initializeFeatureStory(report, featuresInitialized) {
38 |
39 | Features.openFeatures()
40 |
41 | Features.init(report, window.helper, window.jellyfinRewind.auth)
42 | if (!featuresInitialized) {
43 | Features.render()
44 | }
45 |
46 | }
47 | window.initializeFeatureStory = initializeFeatureStory
48 |
49 | export async function restoreAndPrepareRewind() {
50 |
51 | let rewindReportData = null
52 | let staleReport = false
53 | try {
54 | rewindReportData = {
55 | jellyfinRewindReport: jellyfinRewind.restoreRewindReport()
56 | }
57 |
58 | if (rewindReportData.jellyfinRewindReport.commit !== __COMMITHASH__) {
59 | staleReport = true
60 | }
61 | // check if the report is for the previous year and it's after February
62 | if (rewindReportData.jellyfinRewindReport.year < new Date().getFullYear() && new Date().getMonth() > 1) {
63 | staleReport = true
64 | }
65 |
66 | } catch (err) {
67 | console.warn(`Couldn't restore Rewind report:`, err)
68 | throw new Error(`Couldn't restore Rewind report.`)
69 | }
70 |
71 | return {
72 | rewindReportData,
73 | staleReport,
74 | }
75 | }
76 |
77 | export async function deleteRewind() {
78 | try {
79 | jellyfinRewind.deleteRewindReport()
80 | console.info(`Rewind report deleted successfully!`)
81 | } catch (err) {
82 | console.error(`Couldn't delete Rewind report:`, err)
83 | }
84 | }
85 |
86 | export async function generateRewindReport(options = {}) {
87 |
88 | options.progressCallback = options.progressCallback || function() {}
89 | options.oldReport = options.oldReport || null
90 | const { progressCallback, oldReport } = options
91 |
92 | let reportData
93 | try {
94 |
95 | reportData = await window.jellyfinRewind.generateRewindReport({
96 | year: Number(import.meta.env.VITE_TARGET_YEAR),
97 | progressCallback: progressCallback,
98 | oldReport: oldReport,
99 | })
100 | console.info(`Report generated successfully!`)
101 |
102 | } catch (err) {
103 | throw new Error(`Error while generating the report:`, err)
104 | }
105 |
106 | try {
107 | window.jellyfinRewind.saveRewindReport()
108 | console.info(`Report saved successfully!`)
109 | } catch (err) {
110 | console.error(`Couldn't save Rewind report:`, err)
111 | }
112 |
113 | return reportData
114 |
115 | }
116 | window.generateRewindReport = generateRewindReport
117 |
--------------------------------------------------------------------------------
/src/types.js:
--------------------------------------------------------------------------------
1 | export class PrimaryImage {
2 | constructor ({ parentItemId, primaryTag, blurhash }) {
3 | this.parentItemId = parentItemId
4 | this.primaryTag = primaryTag
5 | this.blurhash = blurhash
6 | }
7 | getUrl(baseUrl, dimensions = 160, quality = 90) {
8 | return `${baseUrl}/Items/${this.parentItemId}/Images/Primary?fillWidth=${dimensions}&fillHeight=${dimensions}&tag=${this.primaryTag}&quality=${quality}`
9 | }
10 | }
11 |
12 | export class BackdropImage {
13 | constructor ({ id, parentItemId, backgroundTag, blurhash }) {
14 | this.id = id
15 | this.parentItemId = parentItemId
16 | this.backgroundTag = backgroundTag
17 | this.blurhash = blurhash
18 | }
19 | getUrl(baseUrl, maxWidth = 2880, quality = 80) {
20 | return `${baseUrl}/Items/${this.parentItemId}/Images/Backdrop/${this.id}?maxWidth=${maxWidth}&tag=${this.backgroundTag}&quality=${quality}`
21 | }
22 | }
23 |
24 | export class Artist {
25 | constructor({ id, name, tracks, images, playCount, uniqueTracks, uniquePlayedTracks, plays, lastPlayed, totalPlayDuration }) {
26 | this.name = name
27 | this.id = id
28 | this.tracks = tracks
29 | this.images = images
30 | this.playCount = playCount
31 | this.uniqueTracks = uniqueTracks
32 | this.uniquePlayedTracks = uniquePlayedTracks
33 | this.plays = plays
34 | this.lastPlayed = lastPlayed
35 | this.totalPlayDuration = totalPlayDuration
36 | }
37 |
38 | /**
39 | * Only return things that are not a long array or large object
40 | * Convert long arrays to their length
41 | */
42 | subsetOnly() {
43 | return {
44 | id: this.id,
45 | name: this.name,
46 | tracks: this.tracks.length,
47 | images: this.images,
48 | playCount: this.playCount,
49 | uniqueTracks: this.uniqueTracks.length,
50 | uniquePlayedTracks: {
51 | jellyfin: this.uniquePlayedTracks.jellyfin.length,
52 | playbackReport: this.uniquePlayedTracks.playbackReport.length,
53 | average: this.uniquePlayedTracks.average.length,
54 | },
55 | plays: this.plays.length,
56 | lastPlayed: this.lastPlayed,
57 | totalPlayDuration: this.totalPlayDuration,
58 | }
59 | }
60 | }
61 |
62 | export class Album {
63 | constructor({ id, name, artists, albumArtist, tracks, year, image, playCount, plays, lastPlayed, totalPlayDuration }) {
64 | this.name = name
65 | this.id = id
66 | this.artists = artists
67 | this.albumArtist = albumArtist
68 | this.tracks = tracks
69 | this.year = year
70 | this.image = image
71 | this.playCount = playCount
72 | this.plays = plays
73 | this.lastPlayed = lastPlayed
74 | this.totalPlayDuration = totalPlayDuration
75 | }
76 |
77 | /**
78 | * Only return things that are not a long array or large object
79 | * Convert long arrays to their length
80 | */
81 | subsetOnly() {
82 | return {
83 | id: this.id,
84 | name: this.name,
85 | artists: this.artists.length,
86 | albumArtist: this.albumArtist,
87 | tracks: this.tracks.length,
88 | year: this.year,
89 | image: this.image,
90 | playCount: this.playCount,
91 | plays: this.plays.length,
92 | lastPlayed: this.lastPlayed,
93 | totalPlayDuration: this.totalPlayDuration,
94 | }
95 | }
96 | }
97 |
98 | export class Track {
99 | constructor({ name, id, artistsBaseInfo, albumBaseInfo, genreBaseInfo, image, year, duration, skips, playCount, plays, mostSuccessivePlays, lastPlayed, totalPlayDuration, isFavorite }) {
100 | this.name = name
101 | this.id = id
102 | this.artistsBaseInfo = artistsBaseInfo
103 | this.albumBaseInfo = albumBaseInfo
104 | this.genreBaseInfo = genreBaseInfo
105 | this.image = image
106 | this.year = year
107 | this.duration = duration
108 | this.skips = skips
109 | this.playCount = playCount
110 | this.plays = plays
111 | this.mostSuccessivePlays = mostSuccessivePlays
112 | this.lastPlayed = lastPlayed
113 | this.totalPlayDuration = totalPlayDuration
114 | this.isFavorite = isFavorite
115 | }
116 |
117 | get albumArtist() {
118 | return this.albumBaseInfo.albumArtistBaseInfo
119 | }
120 |
121 | /**
122 | * Only return things that are not a long array or large object
123 | * Convert long arrays to their length
124 | */
125 | subsetOnly() {
126 | return {
127 | id: this.id,
128 | name: this.name,
129 | artistsBaseInfo: this.artistsBaseInfo,
130 | albumBaseInfo: this.albumBaseInfo,
131 | genreBaseInfo: this.genreBaseInfo,
132 | image: this.image,
133 | year: this.year,
134 | duration: this.duration,
135 | skips: this.skips,
136 | playCount: this.playCount,
137 | plays: this.plays.length,
138 | lastPlayed: this.lastPlayed,
139 | totalPlayDuration: this.totalPlayDuration,
140 | isFavorite: this.isFavorite
141 | }
142 | }
143 |
144 | }
145 |
146 | export class Genre {
147 | constructor({ id, name, tracks, image, playCount, uniqueTracks, uniquePlayedTracks, plays, lastPlayed, totalPlayDuration }) {
148 | this.name = name
149 | this.id = id
150 | this.tracks = tracks
151 | this.image = image
152 | this.playCount = playCount
153 | this.uniqueTracks = uniqueTracks
154 | this.uniquePlayedTracks = uniquePlayedTracks
155 | this.plays = plays
156 | this.lastPlayed = lastPlayed
157 | this.totalPlayDuration = totalPlayDuration
158 | }
159 |
160 | /**
161 | * Only return things that are not a long array or large object
162 | * Convert long arrays to their length
163 | */
164 | subsetOnly() {
165 | return {
166 | id: this.id,
167 | name: this.name,
168 | tracks: this.tracks.length,
169 | image: this.image,
170 | playCount: this.playCount,
171 | uniqueTracks: this.uniqueTracks.length,
172 | uniquePlayedTracks: {
173 | jellyfin: this.uniquePlayedTracks.jellyfin.length,
174 | playbackReport: this.uniquePlayedTracks.playbackReport.length,
175 | average: this.uniquePlayedTracks.average.length,
176 | },
177 | plays: this.plays.length,
178 | lastPlayed: this.lastPlayed,
179 | totalPlayDuration: this.totalPlayDuration
180 | }
181 | }
182 | }
183 |
--------------------------------------------------------------------------------
/style.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: Quicksand;
3 | src: url("fonts/Quicksand/Quicksand-VariableFont_wght.ttf");
4 | }
5 | @font-face {
6 | font-family: QuicksandBold;
7 | src: url("fonts/Quicksand/Quicksand_Bold.otf");
8 | }
9 |
10 | @tailwind base;
11 | @tailwind components;
12 | @tailwind utilities;
13 |
--------------------------------------------------------------------------------
/tailwind.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./**/*.html",
5 | "./main.js",
6 | "./src/**/*.{js,ts,jsx,tsx}",
7 | ],
8 | theme: {
9 | extend: {
10 | fontFamily: {
11 | 'quicksand': ['Quicksand', 'serif', 'system-ui'],
12 | 'quicksand-bold': ['QuicksandBold', 'serif', 'system-ui'],
13 | },
14 | },
15 | },
16 | plugins: [],
17 | }
18 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import * as child from 'child_process';
3 |
4 | const commitHash = child.execSync("git rev-parse --short HEAD").toString().trim()
5 |
6 | export default defineConfig({
7 | define: {
8 | __COMMITHASH__: JSON.stringify(commitHash),
9 | }
10 | })
11 |
12 | console.log(`commitHash:`, commitHash)
13 |
--------------------------------------------------------------------------------