├── .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 | -------------------------------------------------------------------------------- /public/media/jellyfin-banner-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 11 | -------------------------------------------------------------------------------- /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 | ![Jellyfin Rewind Banner](Jellyfin%20Rewind%20Banner.png) 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 | Jellyfin Rewind Logo 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 | 180 | 181 | 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 |

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 | 214 | 215 |
216 | ` 217 | 218 | const connectionHelpDialog = html` 219 |
220 |
221 |
222 |
223 |

How to Connect?

224 | 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 |
252 |
253 |
254 |

Setting up Playback Reporting

255 | 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 | 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 | 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 |
304 |
305 |
306 |

Importing Offline Plays from Finamp (Beta)

307 | 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. 1. Open Finamp
  2. 320 |
  3. 2. Open the side menu / drawer
  4. 321 |
  5. 3. Go to the "Playback History" screen
  6. 322 |
  7. 4. Click the "Share" icon at the top right
  8. 323 |
  9. 5. Save the file, for example by sharing it to a file manager or sending it to yourself via email
  10. 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 | 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 | 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 |
383 | 394 |
395 | 396 | 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 |
  • 437 | 440 | ${user.Name} 441 |
  • 442 | `)} 443 |
    444 | or 445 |
    446 |
  • 453 | Manually enter username 454 |
  • 455 | or 456 |
  • 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 | 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 | 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 | 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 | 592 | 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 | 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 | 696 | 697 | 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 |
718 | ${() => 719 | [`password`, `full`].includes(state.server.loginType) ? html` 720 | 732 | 743 | ` : html` 744 | 755 | ` 756 | } 757 |
758 | 759 | 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 | 789 | 809 | 810 | ${() => state.importingExistingReport ? html` 811 |

Importing, please wait a few seconds...

812 | ` : html` 813 | 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 | 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 | 874 | 875 |
876 | 877 | 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 | 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 | 931 | 948 | 949 | ${() => state.importingLastYearsReport ? html` 950 |

Importing, please wait a few seconds...

951 | ` : html` 952 | 958 | 959 | ${() => !state.auth.config.user.isAdmin ? html` 960 | 966 | ` : null} 967 | 968 | ` 969 | } 970 | ` : html` 971 | 983 | ` 984 | } 985 |
986 | 987 | ${() => buttonLogOut} 988 | 989 |
990 | ` 991 | 992 | const progressBar = html` 993 |
994 | 995 |
996 |
1000 |
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 | 1078 | 1079 | ` : html`
` 1080 | } 1081 | 1082 | ${() => buttonLogOut} 1083 | 1084 |
1085 | ` 1086 | 1087 | const buttonLogOut = html` 1088 | 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 | 1135 | 1136 | 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 | 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 | --------------------------------------------------------------------------------