206 Commits

Author SHA1 Message Date
kuelteabmas
1e18576a88 bugfix(spotify): Fixed reauthentication issue
* Reinserting self.authenticate() back into 401 reauthentication flow
2025-05-18 22:03:02 -04:00
kuelteabmas
ff99475fab bugfix(spotify): Updated functionality to fetch access_token and client_id required for making requests
* Removed deprecated session_data and its logic
* Added TOTP for new _get_access_token_and_client_id method
* Added some loggers
2025-05-18 20:05:21 -04:00
kuelteabmas
54d012009e bugfix(spotify): Updated script to be parsed for session_data from config to appServerConfig script
- config script is no longer returned from Fetch session data call from Spotify
- it's now called appServerConfig which contains the correlationId needed to make Spotify requests
-
2025-05-15 17:03:03 -04:00
Kamil Kosek
de7b58d7b2 Merge pull request #74 from kamilkosek/dev
Dev
2025-02-11 13:49:54 +01:00
Kamil Kosek
68f17e84ed Merge pull request #75 from kamilkosek/main
main to dev
2025-02-11 13:38:30 +01:00
Kamil
f48186bcf6 Update spotdl version to 4.2.11 in requirements.txt 2025-02-11 12:32:46 +00:00
Kamil Kosek
ed8fb70500 Merge pull request #71 from Daniel-Boluda/patch-1
add CACHE_REDIS_HOST as env variable to config.py
2025-02-11 11:26:16 +01:00
Kamil
96b5cd5928 . 2025-02-11 09:35:35 +00:00
Kamil
006a3ce32e Bump version: "0.1.9" → 0.1.10 2025-02-11 09:35:10 +00:00
Kamil
a08c9c7800 . 2025-02-11 09:35:07 +00:00
Kamil
e00513ba52 . 2025-02-11 09:34:42 +00:00
Kamil
f04657a86c . 2025-02-11 09:33:49 +00:00
Kamil
7a6d238610 . 2025-02-11 09:33:01 +00:00
Kamil
ce50e1a8f8 . 2025-02-11 09:28:55 +00:00
Kamil
32c860fbb9 fix: remove unnecessary regex anchors in bumpversion configuration 2025-02-11 09:26:40 +00:00
Kamil
bf725c6b24 fix: improve regex for version parsing in bumpversion configuration 2025-02-11 09:25:32 +00:00
Kamil
bb195b1c77 fix: update version regex in bumpversion configuration 2025-02-11 09:24:31 +00:00
Kamil
e66208b49e add pylintrc configuration to control message settings 2025-02-11 09:24:24 +00:00
Kamil
9e675f8cf4 feat: add batch processing for adding and removing songs in playlists 2025-02-11 09:15:32 +00:00
Kamil
8a883edf07 feat: handle empty datetime input in human-readable filter 2025-02-11 09:15:07 +00:00
Kamil
e9fa5f8994 Preperation for deezer 2025-02-11 08:32:36 +00:00
Daniel Boluda
1b91110768 add CACHE_REDIS_HOST as env variable to config.py
This is usefull for hosts not being "redis"
2025-01-26 14:37:15 +01:00
Kamil Kosek
61604950c3 Merge pull request #67 from Ofirfr/debug_spotdl
spotDL exec command debug log
2025-01-21 09:31:55 +01:00
Kamil Kosek
58674d4c26 Merge pull request #66 from Ofirfr/docker_ignore
make docker image slimmer
2025-01-21 09:30:36 +01:00
Ofirfr
d146e78132 upgrade spotdl for better performence 2025-01-16 20:26:11 +02:00
Ofirfr
3ceae962b1 adjust for cookie_file might not be set 2025-01-13 22:02:51 +02:00
Ofirfr
6f051cb167 notify about performence effects of params 2025-01-13 22:02:37 +02:00
Ofirfr
42325742f0 sanitize config 2025-01-13 22:02:14 +02:00
Ofirfr
8ad5ff0860 spotDL exec command debug log 2025-01-11 23:30:27 +02:00
Ofirfr
92407a2ee0 make docker image slimmer 2025-01-11 22:01:21 +02:00
Kamil
7af86c926f refactor: remove preview button from track table template 2024-12-18 09:23:41 +00:00
Kamil
580906dc78 feat: add Jellyfin link button filter and integrate into playlist info template 2024-12-18 09:22:34 +00:00
Kamil
917ec9542f feat: add human-readable datetime filter and update playlist info template 2024-12-18 09:00:12 +00:00
Kamil
b9530a159c feat: make playlist item image clickable to view details 2024-12-17 17:51:22 +00:00
Kamil
fffeac8c74 fix: update Monaco Editor version in log view template 2024-12-17 17:45:15 +00:00
Kamil
4d06b257cb feat: add refresh playlist functionality 2024-12-17 17:45:06 +00:00
Kamil
8c9fb43f01 fix: update track order in playlist when moving tracks
Fixes #53
2024-12-17 17:43:31 +00:00
Kamil Kosek
69c5f7093e Merge dev 0.1.9 into main
Merge dev into main
2024-12-14 00:13:41 +01:00
Kamil
34ae3c5680 update v0.1.9 changelog 2024-12-13 22:58:01 +00:00
Kamil
56d937a21f fix: improve logging for Jellyfin ID updates and error handling 2024-12-13 22:40:56 +00:00
Kamil
3a78e710ae chore: add settings.yaml to .gitignore 2024-12-13 14:39:56 +00:00
Kamil
1b95f201be feat: add YAML settings management and admin settings page, add default_playlist_users 2024-12-13 08:27:55 +00:00
Kamil
b7de39e501 chore: update requirements.txts add pyyaml 2024-12-13 08:27:11 +00:00
Kamil
9c46be1701 fix: include commit SHA in tag name for GitHub release workflow 2024-12-12 12:23:50 +00:00
Kamil
43adf12755 Enable generation of release notes in GitHub Actions workflow 2024-12-12 12:21:39 +00:00
Kamil
9de58731c0 chore: update version format to include 'v' prefix and adjust related configurations 2024-12-12 12:14:41 +00:00
Kamil
969eca4a04 fix: remove backdrop from select user dialog 2024-12-12 12:05:06 +00:00
Kamil
9667b71d24 feat: update page title to include 'Jellyplist' prefix 2024-12-12 12:04:37 +00:00
Kamil
4d9e6162fc feat: add quality_score field to Track model and update related functionality
Fixes #51
2024-12-11 20:33:13 +00:00
Kamil
b877ee04e3 fix: increase maximum length of track name to 200 characters 2024-12-11 19:36:26 +00:00
Kamil
d6a702b606 feat: initialize additional_users variable in add_playlist function 2024-12-11 19:33:41 +00:00
Kamil
d615bafd1f feat: add admin functionality to dynamically load and manage users for playlist addition 2024-12-11 14:15:40 +00:00
Kamil
a44c5b5209 feat: refactor add_jellyfin_user_to_playlist to use internal method for user assignment 2024-12-11 14:15:28 +00:00
Kamil
67d2b3cb9e feat: enhance add_playlist function to support JSON input and manage additional users for playlists 2024-12-11 14:15:15 +00:00
Kamil
6248c54829 fix: update modal dialog class to improve responsiveness on larger screens 2024-12-11 12:54:47 +00:00
Kamil
2da69fc330 feat: add workaround method to remove user from playlist and enhance get_users method with optional user_id 2024-12-11 12:54:28 +00:00
Kamil
0c57912053 feat: implement Jellyfin user management routes for playlists 2024-12-11 12:54:18 +00:00
Kamil
423ffbb608 fix: clean up user management modal script and improve badge styling 2024-12-11 12:54:04 +00:00
Kamil
d9302434c2 feat: add user management functionality for playlists with dynamic loading and confirmation prompts 2024-12-11 08:52:31 +00:00
Kamil
debe273cfb fix: remove unnecessary .mp3 extension checks in download_missing_tracks method 2024-12-11 00:02:14 +00:00
Kamil
f9e8be1824 fix: update playlist link text to "My Playlists" and simplify admin access condition 2024-12-10 22:25:08 +00:00
Kamil
cdf7d8ffe9 Revert "fix: update playlist link text and simplify admin access condition"
This reverts commit 41c62a5376.
2024-12-10 22:24:17 +00:00
Kamil
41c62a5376 fix: update playlist link text and simplify admin access condition 2024-12-10 22:15:28 +00:00
Kamil
4f06f81e93 fix: update unlock_key route to use task_manager for releasing locks 2024-12-10 22:10:46 +00:00
Kamil
754f7f9204 feat: add get_users method to fetch users from Jellyfin API 2024-12-10 22:10:10 +00:00
Kamil
01cc78eb93 fix: improve log rendering by removing unnecessary replacements and ensure safe HTML output 2024-12-10 21:48:18 +00:00
Kamil
79c9554ce2 fix: enhance error handling and logging in tasks 2024-12-10 20:56:01 +00:00
Kamil
500a049976 fix: update supervisord configuration to use stdout and stderr for logging 2024-12-10 20:55:38 +00:00
Kamil
477c869107 fix: correct typo in output directory variable name in download_missing_tracks function 2024-12-10 20:30:55 +00:00
Kamil Kosek
731d2db083 Update manual-build.yml 2024-12-10 17:31:52 +01:00
Kamil Kosek
3862730203 Update manual-build.yml 2024-12-10 17:27:17 +01:00
Kamil
11bd25e5be Bump version: 0.1.8 → 0.1.9 2024-12-10 15:54:28 +00:00
Kamil
c78ceef508 add changelog reading step to manual build workflow for release notes 2024-12-10 15:51:06 +00:00
Kamil
aa201c3be2 add new environment variables for spotDL configuration and output format control 2024-12-10 15:47:38 +00:00
Kamil
6f3f5b9623 implement caching for provider playlists to optimize API calls and improve performance 2024-12-10 15:44:03 +00:00
Kamil
4106524710 add caching for fetching provider playlists to reduce API calls and increase loading speed in UI 2024-12-10 15:41:44 +00:00
Kamil
be37d4cffe update log format to increase function name width in logging output 2024-12-10 15:41:04 +00:00
Kamil
4deb7387aa enhance track download process with dynamic file path generation and output format configuration. 2024-12-10 14:57:42 +00:00
Kamil
6129bee98c pinned postgres to 17.2 2024-12-10 12:27:18 +00:00
Kamil
23d121e58f add proxy support for SpotDL in download process
Fixes #35
2024-12-10 12:23:16 +00:00
Kamil
7676189625 add log viewer features and set log level functionality in admin panel
add "Get Logs for a new Release", which will create preformatted markdown text you can paste directly to the issue
2024-12-10 11:44:01 +00:00
Kamil
798c4ae28d update readme.md to define IMAGE variable and adjust volume mappings for jellyplist services and prepare compose sample for 0.1.9 release 2024-12-09 20:38:38 +00:00
Kamil
eeb6ad9172 update .dockerignore to ignore all cookie files 2024-12-09 20:37:50 +00:00
Kamil
92e8963727 add rotating file handler for logging based on worker type 2024-12-09 10:25:44 +00:00
Kamil
d54100cbc4 add supervisor support to Dockerfile and create supervisord configuration 2024-12-09 10:20:04 +00:00
Kamil
e559b1cf11 return task info on manual start
Fixes #38
Fixes #22
2024-12-09 10:16:58 +00:00
Kamil
631b2a35f7 Merge branch 'dev' of https://github.com/kamilkosek/jellyplist into dev 2024-12-09 09:52:31 +00:00
Kamil Kosek
ad5957b539 Merge pull request #37 from artyorsh/patch-1
case-insensitive image formats
2024-12-09 10:51:56 +01:00
Kamil Kosek
e2bea2c151 Update main.yml
Remove automatic Build
2024-12-09 10:51:20 +01:00
Artur Y
181eff22ef add image/webp to the list of supported cover image types 2024-12-07 13:27:45 +01:00
Artur Y
a20f1733f1 case-insensitive image formats
Sometimes the image format comes as `image/JPEG`, which results in an `Unsupported image format: image/JPEG`
2024-12-06 15:33:22 +01:00
Kamil Kosek
0f4d599308 Merge pull request #34 from kamilkosek/main
merge back
2024-12-06 11:56:18 +01:00
Kamil
9acf3bde84 Fixed missing lock keys to task manager and task status rendering 2024-12-06 08:23:00 +00:00
Kamil Kosek
804b2bfe7e Merge pull request #31 from kamilkosek/dev
Merge dev 0.1.8 into main
2024-12-06 01:44:26 +01:00
Kamil
4c675e814c Bump version: 0.1.7 → 0.1.8 2024-12-06 00:43:43 +00:00
Kamil
1509c37cd9 Enhance download progress reporting with detailed state updates 2024-12-06 00:40:45 +00:00
Kamil
24ba4a0b70 doesnt work 2024-12-06 00:34:08 +00:00
Kamil
7a7ef8d7bc another update to build workflow 2024-12-06 00:22:35 +00:00
Kamil
be9a72701e added changelog 0.1.8 2024-12-06 00:06:49 +00:00
Kamil
c5de8d9841 Added check for update 2024-12-06 00:05:36 +00:00
Kamil
671b813e6c Updated manual_build.yml to not mark dev builds as latest 2024-12-06 00:04:45 +00:00
Kamil
b29a7bbbe3 typo fixed 2024-12-05 23:28:21 +00:00
Kamil
d4c3a67249 remove static branch name from version 2024-12-05 09:40:56 +00:00
Kamil
4be027bb35 create github release on dev release 2024-12-05 09:39:17 +00:00
Kamil
39e44e0606 update manual build 2024-12-05 09:26:13 +00:00
Kamil
8c30c6183d Added notice about breaking change in 0.1.7 2024-12-05 09:15:05 +00:00
Kamil
9e7f331c49 Updated manual-build workflow to include dev version tag 2024-12-05 09:14:49 +00:00
Kamil
bb856c96a1 Fixed wrong output_path creation 2024-12-05 09:14:28 +00:00
Kamil Kosek
14a8fdc127 Merge pull request #28 from kamilkosek/dev
Merge dev into main , part 2
2024-12-05 00:55:33 +01:00
Kamil
1d24972ea0 Updated gitignore again 2024-12-04 23:54:33 +00:00
Kamil
7b15a8b53d Remove auto gen release notes temporarliy 2024-12-04 23:54:24 +00:00
Kamil
d41b901649 Fix Typo 2024-12-04 23:53:57 +00:00
Kamil Kosek
f7372fed38 Merge pull request #27 from kamilkosek/dev
Merge dev into main
2024-12-05 00:46:51 +01:00
Kamil
89a1bc21be Updated readme and changelog 0.1.7 2024-12-04 23:38:13 +00:00
Kamil
360c4e5b7a changed request-lidarr-schedule to x:50 2024-12-04 23:37:40 +00:00
Kamil
d69ac22998 fixed function call 2024-12-04 22:58:22 +00:00
Kamil
d9dabd0a9c test arm build on dev 2024-12-04 22:26:42 +00:00
Kamil
087d44836f Merge branch 'pr-26' into dev 2024-12-04 22:24:42 +00:00
Kamil
1ee0087b8f reworked the celery task management 2024-12-04 22:22:04 +00:00
Kamil
e2d37b77b0 Added: MUSIC_STORAGE_BASE_PATH env variable 2024-12-04 22:21:15 +00:00
Kamil
86f5bf118a updated .gitignore 2024-12-04 22:19:44 +00:00
Artur Y
e43d36dd24 build docker image for amd64 and arm64 2024-12-04 21:52:56 +01:00
Kamil
30ea28ed6e session commit :[ 2024-12-04 00:18:34 +00:00
Kamil
07503a8003 update path and download state if the track has a jellyfin id set. 2024-12-04 00:03:29 +00:00
Kamil
9a5adfaa5b Add SPOTIFY_COOKIE_FILE env var and handle correctly when its missing 2024-12-03 23:20:35 +00:00
Kamil
b9ad5be7bc Bump version: 0.1.6 → 0.1.7 2024-12-03 23:11:50 +00:00
Kamil
b861a1a8f4 feat: added lidarr support 2024-12-03 23:11:05 +00:00
Kamil
87791cf21d - Support multiple music providers
- feat: Doubleclick on track in Table view to get technical information about it
2024-12-03 12:48:27 +00:00
Kamil
883294d74e added get_item to jellyfin-client 2024-12-03 12:45:13 +00:00
Kamil
2b3c400c10 Major Overhaul:
- No more dict´s , goal is to have type safety and a generic approach to support multiple music (playlist) providers
- removed unneeded functions
2024-12-03 12:44:40 +00:00
Kamil
00ba693fb9 added jellyfin_link filter 2024-12-03 12:39:43 +00:00
Kamil
cd498988ae added dataclasses for combined information about track/playlist from provider and database 2024-12-03 12:39:25 +00:00
Kamil
3a26c054a0 added blueprint and restructured existing routes 2024-12-03 12:38:47 +00:00
Kamil
d5aee793a0 updated .gitignore to not include any cookies at all 2024-12-03 12:35:45 +00:00
Kamil
6b78b90ee7 feature: add possibility to directly add a playlist from the / page 2024-12-03 12:35:23 +00:00
Kamil
d70c3b3913 Major Overhaul: Cleanup Unused Files 2024-12-03 12:32:11 +00:00
Kamil
e428629928 refacotring db to work with multiple music provider 2024-11-29 22:54:22 +00:00
Kamil
94d401a99f changed "spotify" to "provider" 2024-11-29 22:50:10 +00:00
Kamil
56aaec603b refactor to start working with blueprints 2024-11-29 22:48:46 +00:00
Kamil
aa718eb628 Typings pyi 2024-11-29 22:48:29 +00:00
Kamil
33ccbc470c Added Identifier to base and implementation 2024-11-29 22:48:06 +00:00
Kamil
3c25cd70ea Added MusicProviderRegistry 2024-11-29 22:47:46 +00:00
Kamil
25e51f1ef2 implemented browse_all and browse_page , should be enough for jellyplist 2024-11-29 22:07:11 +00:00
Kamil
7232b3223d further implementations 2024-11-29 20:49:36 +00:00
Kamil
f81188f7e3 spotify client using generic base classes 2024-11-29 19:55:27 +00:00
Kamil
cbe172ff1f base classes for generic musicProviderClient 2024-11-29 19:55:07 +00:00
Kamil
d8d677bc1b UI Fix on Task overview 2024-11-27 20:08:03 +00:00
Kamil
18dc6e18af rework on find_best_match_from_jellyfin
-  Artists will be compared, even if only one search result from jellyfin #24
2024-11-27 20:07:13 +00:00
Kamil
3bcecfe6fd Show loading spinner on jellyfin library search 2024-11-27 17:26:00 +00:00
Kamil
1867f982a0 use typed AudioProfile class 2024-11-27 17:25:39 +00:00
Kamil
b60a882dab Better Errorhandling in case of spotify api errors
Addresses issue #20
2024-11-27 17:20:47 +00:00
Kamil
c6eb95112e adjusted highlight filter, to highlight a perfect match 2024-11-27 17:15:35 +00:00
Kamil
c9363104ec Added SPOTIFY_COUNTRY_CODE env var, defaults to 'DE'
- before it was hard coded to DE
2024-11-27 16:07:08 +00:00
Kamil
dc0165957a updated gitignore 2024-11-27 16:04:45 +00:00
Kamil
ddf73b77db provide more technical track details in the ui
Fixes #15
2024-11-27 16:03:39 +00:00
Kamil Kosek
6ec7e223ce Update main.yml 2024-11-26 16:53:14 +01:00
Kamil
da2b725b22 updated github workflow no2 2024-11-26 15:49:40 +00:00
Kamil Kosek
2a5d1dd425 Merge pull request #19 from kamilkosek/dev
Merge Dev into 0.1.6
2024-11-26 16:46:32 +01:00
Kamil
4f82ba6aab updated github workflow 2024-11-26 15:42:34 +00:00
Kamil
b64d9bf8fc Bump version: 0.1.5 → 0.1.6 2024-11-26 15:40:58 +00:00
Kamil
78d96c2ccc changelog and readme update 2024-11-26 15:40:56 +00:00
Kamil
a436a0ad91 allow manual track relinking
fixes #13
2024-11-26 15:23:08 +00:00
Kamil
0ca91b7d7b Added Progressbar for task status 2024-11-26 15:14:30 +00:00
Kamil
af662df434 - Added unlock_key endpoint
- Added skip_endpoints so the alert part isnt rendered on task_status query
2024-11-26 15:14:06 +00:00
Kamil
7e24016788 Fix: Increment processed_tracks when best_match is found during download task 2024-11-26 15:11:13 +00:00
Kamil
16e1a8a58d Unblock redis locks 2024-11-26 15:09:29 +00:00
Kamil
0fe45483dc small fixes 2024-11-26 13:23:09 +00:00
Kamil
b010c8950e tasks rework:
- more verbose logging
- better handling of already downloaded files
- perform upgrades if file with better quality is found in jellyfin
2024-11-25 17:39:22 +00:00
Kamil
bdab83e464 UI update to be able to wipe playlist 2024-11-25 17:20:30 +00:00
Kamil
f0bffe92ae updated jellyfin api client:
- more verbose debug logs
2024-11-25 17:20:07 +00:00
Kamil
fce13015ea - add_playlist() now adds the whole playlist instead of first 100 items
- added wipe_playlist endpoint to completely remove playlist from jellyplist and jellyfin
2024-11-25 17:18:16 +00:00
Kamil
b49e7cc05c added get_longest_substring() to perform better search on jellyfin api 2024-11-25 17:17:07 +00:00
Kamil
e497b33ccd change update_all_playlists_track_status-schedule to every 5 minutes 2024-11-25 17:16:37 +00:00
Kamil
543a1359f2 added FIND_BEST_MATCH_USE_FFPROBE env var 2024-11-25 17:16:16 +00:00
Kamil
8392e37592 Added: JELLYPLIST_DB_PORT env var
Fixes #8
2024-11-25 09:11:06 +00:00
Kamil
0401e6481e 😣 2024-11-24 22:45:02 +00:00
Kamil
996daf700a some logging adjustments 2024-11-24 22:44:05 +00:00
Kamil
4d9f9462f5 fix copy pasta errors due to tiredness 2024-11-24 22:10:29 +00:00
Kamil
a84ae01e55 indicate dev build 2024-11-24 22:10:03 +00:00
Kamil
7de92f01ec remove unnecessary requirement 2024-11-24 21:47:30 +00:00
Kamil
3c006ed031 fix error 2024-11-24 21:45:49 +00:00
Kamil
3e593bf475 updated logging on jellyfin api client 2024-11-24 21:39:40 +00:00
Kamil
3f5318a17b Updated build yaml for dev container 2024-11-24 21:15:04 +00:00
Kamil
e5416ed800 Added LOG_LEVEL env var, to make it configurable through .env 2024-11-24 21:14:47 +00:00
Kamil
961e175a7d Missed some timeout parameters 2024-11-24 21:07:34 +00:00
Kamil
810febbec2 Added JELLYFIN_REQUEST_TIMEOUT env var 2024-11-24 21:05:11 +00:00
Kamil
82390455d0 Remove cookies.txt requirement
fixes #5
2024-11-24 16:23:54 +00:00
Kamil
e3d37576ed Added REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK
resolves #10
2024-11-24 16:17:30 +00:00
Kamil
6fe5c0fae7 Updated readme 2024-11-24 16:14:29 +00:00
Kamil
68ab47c443 Updated readme 2024-11-24 16:14:21 +00:00
Kamil Kosek
e32bfb63e0 Merge pull request #4 from kamilkosek/dev
Merge 0.1.5 to main
2024-11-23 11:02:13 +01:00
Kamil
4a14e48938 Bump version: 0.1.4 → 0.1.5 2024-11-23 09:52:40 +00:00
Kamil
d03ce01cb3 readme update 2024-11-23 09:52:33 +00:00
Kamil
42ed70df3b updated config key name 2024-11-23 09:52:26 +00:00
Kamil Kosek
1c4030a4c6 Merge pull request #3 from kamilkosek/dev
Merge 0.1.4 into main
2024-11-22 23:00:05 +01:00
Kamil
31556fd207 Merge branch 'dev' of https://github.com/kamilkosek/jellyplist into dev 2024-11-22 21:58:03 +00:00
Kamil
42c92caef7 Bump version: 0.1.3 → 0.1.4 2024-11-22 21:56:48 +00:00
Kamil
7f075fb490 fixed wrong function call 2024-11-22 21:56:29 +00:00
Kamil
53e4cf0a8d try some normalization on search query 2024-11-22 21:56:15 +00:00
Kamil Kosek
7a4ef7f312 Merge pull request #2 from kamilkosek/dev
Merge 0.1.3 into main
2024-11-22 22:10:39 +01:00
Kamil
ef34aaa7a7 Bump version: 0.1.2 → 0.1.3 2024-11-22 21:08:10 +00:00
Kamil
313db2b71a use get_cached_spotify_track 2024-11-22 21:07:06 +00:00
Kamil Kosek
84891ef548 Merge pull request #1 from kamilkosek/dev
Merge dev into main
2024-11-22 22:03:34 +01:00
Kamil
7177581a4c Publish "latest" on main branch 2024-11-22 21:02:33 +00:00
Kamil
f9993959ed publish with multiple tags 2024-11-22 20:48:20 +00:00
Kamil
6f56f25384 do not use clean query 2024-11-22 20:37:19 +00:00
67 changed files with 5287 additions and 1203 deletions

View File

@@ -1,12 +1,12 @@
[bumpversion]
current_version = 0.1.2
current_version = 0.1.10
commit = True
tag = True
[bumpversion:file:app/version.py]
search = __version__ = "{current_version}"
replace = __version__ = "{new_version}"
search = __version__ = "v{current_version}"
replace = __version__ = "v{new_version}"
[bumpversion:file:version.py]
search = __version__ = "{current_version}"
replace = __version__ = "{new_version}"
search = __version__ = "v{current_version}"
replace = __version__ = "v{new_version}"

View File

@@ -11,7 +11,12 @@ __pycache__/
.DS_Store
# Ignore Git files
.git
cookies*
.git*
*cookies*
set_env.sh
jellyplist.code-workspace
jellyplist.code-workspace
# Ignore GitHub page related files
changelogs
readme.md
screenshots

View File

@@ -1,9 +1,12 @@
name: Build and Release on Tag
on:
push:
branches:
- main
workflow_dispatch:
inputs:
branch:
description: 'Branch to build the Docker image from'
required: true
default: 'main'
jobs:
build-and-publish:
@@ -37,8 +40,33 @@ jobs:
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ghcr.io/${{ github.repository }}:${{ env.VERSION }}
tags: |
ghcr.io/${{ github.repository }}:${{ env.VERSION }}
ghcr.io/${{ github.repository }}:latest
# Read changelog for the current version
- name: Read Changelog
id: changelog
run: |
if [ -f changelogs/${{ env.VERSION }}.md ]; then
changelog_content=$(cat changelogs/${{ env.VERSION }}.md)
echo "CHANGELOG_CONTENT<<EOF" >> $GITHUB_ENV
echo "$changelog_content" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
else
echo "CHANGELOG_CONTENT=No changelog available for this release." >> $GITHUB_ENV
fi
# # Generate auto-generated release notes
# - name: Generate Auto Release Notes
# id: release_notes
# run: |
# auto_notes=$(gh release create ${{ env.VERSION }} --generate-notes --prerelease --json body --jq .body)
# echo "AUTO_RELEASE_NOTES<<EOF" >> $GITHUB_ENV
# echo "$auto_notes" >> $GITHUB_ENV
# echo "EOF" >> $GITHUB_ENV
# Create a release on GitHub
- name: Create GitHub Release
@@ -46,6 +74,9 @@ jobs:
with:
tag_name: ${{ env.VERSION }}
name: Release ${{ env.VERSION }}
body: |
${{ env.CHANGELOG_CONTENT }}
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -18,6 +18,34 @@ jobs:
uses: actions/checkout@v3
with:
ref: ${{ github.event.inputs.branch }}
# Extract branch name and latest commit SHA
- name: Extract branch name and commit SHA
id: branch_info
run: |
echo "BRANCH_NAME=${{ github.event.inputs.branch }}" >> $GITHUB_ENV
echo "COMMIT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
# Create a file indicating this is a dev build
- name: Create DEV_BUILD file
run: |
echo "${{ env.BRANCH_NAME }}-${{ env.COMMIT_SHA }}" > DEV_BUILD
- name: Extract Version
id: extract_version
run: |
version=$(python3 -c "import version; print(f'{version.__version__}')")
echo "VERSION=$version" >> $GITHUB_ENV
- name: Read Changelog
id: changelog
run: |
if [ -f changelogs/${{ env.VERSION }}.md ]; then
changelog_content=$(cat changelogs/${{ env.VERSION }}.md)
echo "CHANGELOG_CONTENT<<EOF" >> $GITHUB_ENV
echo "$changelog_content" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
else
echo "CHANGELOG_CONTENT=No changelog available for this release." >> $GITHUB_ENV
fi
# Set up Docker
- name: Set up Docker Buildx
@@ -36,5 +64,23 @@ jobs:
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
tags: |
ghcr.io/${{ github.repository }}:${{ env.COMMIT_SHA }}
ghcr.io/${{ github.repository }}:${{ env.BRANCH_NAME }}
ghcr.io/${{ github.repository }}:${{ env.VERSION }}-${{ env.BRANCH_NAME}}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: |
${{ env.VERSION }}-${{ env.BRANCH_NAME }}-${{ env.COMMIT_SHA }}
name: |
${{ env.BRANCH_NAME }} Release ${{ env.VERSION }}
body: |
${{ env.CHANGELOG_CONTENT }}
generate_release_notes: true
make_latest: false

8
.gitignore vendored
View File

@@ -73,6 +73,10 @@ coverage/
# macOS
.DS_Store
.cache
cookies*.txt
*cookies*.txt
*.code-workspace
set_env.sh
set_env.sh
notes.md
DEV_BUILD
payload.json
settings.yaml

2
.pylintrc Normal file
View File

@@ -0,0 +1,2 @@
[MESSAGES CONTROL]
disable=logging-fstring-interpolation,broad-exception-raised

View File

@@ -8,7 +8,7 @@ WORKDIR /jellyplist
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
RUN apt update
RUN apt install ffmpeg netcat-openbsd -y
RUN apt install ffmpeg netcat-openbsd supervisor -y
# Copy the application code
COPY . .
COPY entrypoint.sh /entrypoint.sh
@@ -16,6 +16,7 @@ RUN chmod +x /entrypoint.sh
# Expose the port the app runs on
EXPOSE 5055
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Set the entrypoint
@@ -23,4 +24,4 @@ ENTRYPOINT ["/entrypoint.sh"]
# Run the application
CMD ["python", "run.py"]
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View File

@@ -1,6 +1,8 @@
from logging.handlers import RotatingFileHandler
import os
import threading
import time
import yaml
from flask_socketio import SocketIO
import sys
@@ -8,6 +10,7 @@ from flask import Flask, has_request_context
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from psycopg2 import OperationalError
import redis
import spotipy
from spotipy.oauth2 import SpotifyClientCredentials
from celery import Celery
@@ -21,6 +24,7 @@ from flask_caching import Cache
from .version import __version__
def check_db_connection(db_uri, retries=5, delay=5):
"""
Check if the database is reachable.
@@ -70,63 +74,88 @@ def make_celery(app):
},
'update_all_playlists_track_status-schedule': {
'task': 'app.tasks.update_all_playlists_track_status',
'schedule': crontab(minute='*/2'),
'schedule': crontab(minute='*/5'),
},
'update_jellyfin_id_for_downloaded_tracks-schedule': {
'task': 'app.tasks.update_jellyfin_id_for_downloaded_tracks',
'schedule': crontab(minute='*/10'),
}
}
if app.config['LIDARR_API_KEY']:
celery.conf.beat_schedule['request-lidarr-schedule'] = {
'task': 'app.tasks.request_lidarr',
'schedule': crontab(minute='50')
}
celery.conf.timezone = 'UTC'
return celery
log_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s : %(message)s')
# Why this ? Because we are using the same admin login for web, worker and beat we need to distinguish the device_id´s
device_id = f'JellyPlist_{'_'.join(sys.argv)}'
# Initialize Flask app
app = Flask(__name__, template_folder="../templates", static_folder='../static')
# log_file = 'app.log'
# handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=3)
# handler.setLevel(logging.DEBUG)
# handler.setFormatter(log_formatter)
# stream_handler = logging.StreamHandler(sys.stdout)
# stream_handler.setLevel(logging.DEBUG)
# stream_handler.setFormatter(log_formatter)
# # app.logger.addHandler(handler)
# app.logger.addHandler(stream_handler)
app.config.from_object(Config)
app.logger.setLevel(logging.DEBUG)
app.config['runtime_settings'] = {}
yaml_file = 'settings.yaml'
def load_yaml_settings():
with open(yaml_file, 'r') as f:
app.config['runtime_settings'] = yaml.safe_load(f)
def save_yaml_settings():
with open(yaml_file, 'w') as f:
yaml.dump(app.config['runtime_settings'], f)
for handler in app.logger.handlers:
app.logger.removeHandler(handler)
log_level = getattr(logging, app.config['LOG_LEVEL'], logging.INFO) # Default to DEBUG if invalid
app.logger.setLevel(log_level)
FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)36s() ] %(levelname)7s - %(message)s"
logging.basicConfig(format=FORMAT)
# Add RotatingFileHandler to log to a file
# if worker is in sys.argv, we are running a celery worker, so we log to a different file
if 'worker' in sys.argv:
log_file = os.path.join("/var/log/", 'jellyplist_worker.log')
elif 'beat' in sys.argv:
log_file = os.path.join("/var/log/", 'jellyplist_beat.log')
else:
log_file = os.path.join("/var/log/", 'jellyplist.log')
file_handler = RotatingFileHandler(log_file, maxBytes=2 * 1024 * 1024, backupCount=10)
file_handler.setFormatter(logging.Formatter(FORMAT))
app.logger.addHandler(file_handler)
Config.validate_env_vars()
cache = Cache(app)
redis_client = redis.StrictRedis(host=app.config['CACHE_REDIS_HOST'], port=app.config['CACHE_REDIS_PORT'], db=0, decode_responses=True)
# Spotify, Jellyfin, and Spotdl setup
app.logger.info(f"setting up spotipy")
sp = spotipy.Spotify(auth_manager=SpotifyClientCredentials(
client_id=app.config['SPOTIPY_CLIENT_ID'],
client_secret=app.config['SPOTIPY_CLIENT_SECRET']
client_id=app.config['SPOTIFY_CLIENT_ID'],
client_secret=app.config['SPOTIFY_CLIENT_SECRET']
))
app.logger.info(f"setting up jellyfin client")
app.logger.info(f"setting up jellyfin client, BaseUrl = {app.config['JELLYFIN_SERVER_URL']}, timeout = {app.config['JELLYFIN_REQUEST_TIMEOUT']}")
jellyfin = JellyfinClient(app.config['JELLYFIN_SERVER_URL'])
jellyfin = JellyfinClient(app.config['JELLYFIN_SERVER_URL'], app.config['JELLYFIN_REQUEST_TIMEOUT'])
jellyfin_admin_token, jellyfin_admin_id, jellyfin_admin_name, jellyfin_admin_is_admin = jellyfin.login_with_password(
app.config['JELLYFIN_ADMIN_USER'],
app.config['JELLYFIN_ADMIN_PASSWORD'], device_id= device_id
)
# SQLAlchemy and Migrate setup
app.logger.info(f"connecting to db: {app.config['JELLYPLIST_DB_HOST']}")
check_db_connection(f'postgresql://{app.config["JELLYPLIST_DB_USER"]}:{app.config["JELLYPLIST_DB_PASSWORD"]}@{app.config["JELLYPLIST_DB_HOST"]}/jellyplist',retries=5,delay=2)
app.config['SQLALCHEMY_DATABASE_URI'] = f'postgresql://{app.config['JELLYPLIST_DB_USER']}:{app.config['JELLYPLIST_DB_PASSWORD']}@{app.config['JELLYPLIST_DB_HOST']}/jellyplist'
app.logger.info(f"connecting to db: {app.config['JELLYPLIST_DB_HOST']}:{app.config['JELLYPLIST_DB_PORT']}")
db_uri = f'postgresql://{app.config["JELLYPLIST_DB_USER"]}:{app.config["JELLYPLIST_DB_PASSWORD"]}@{app.config["JELLYPLIST_DB_HOST"]}:{app.config['JELLYPLIST_DB_PORT']}/jellyplist'
check_db_connection(db_uri=db_uri,retries=5,delay=2)
app.config['SQLALCHEMY_DATABASE_URI'] = db_uri
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
app.logger.info(f"applying db migrations")
@@ -138,14 +167,78 @@ app.config.update(
result_backend=app.config['REDIS_URL']
)
def read_dev_build_file(file_path="/jellyplist/DEV_BUILD"):
if os.path.exists(file_path):
with open(file_path, "r") as file:
content = file.read().strip()
return f"-{content}"
else:
return ''
app.logger.info(f"initializing celery")
celery = make_celery(app)
socketio = SocketIO(app, message_queue=app.config['REDIS_URL'], async_mode='eventlet')
celery.set_default()
app.logger.info(f'Jellyplist {__version__} started')
from app import routes
from app import jellyfin_routes, tasks
if "worker" in sys.argv:
tasks.release_lock("download_missing_tracks_lock")
app.logger.info(f'Jellyplist {__version__}{read_dev_build_file()} started')
app.logger.debug(f"Debug logging active")
from app.routes import pl_bp, routes, jellyfin_routes
app.register_blueprint(pl_bp)
from app import filters # Import the filters dictionary
# Register all filters
for name, func in filters.filters.items():
app.jinja_env.filters[name] = func
from .providers import SpotifyClient
if app.config['SPOTIFY_COOKIE_FILE']:
if os.path.exists(app.config['SPOTIFY_COOKIE_FILE']):
spotify_client = SpotifyClient(app.config['SPOTIFY_COOKIE_FILE'])
else:
app.logger.error(f"Cookie file {app.config['SPOTIFY_COOKIE_FILE']} does not exist. Exiting.")
sys.exit(1)
else:
spotify_client = SpotifyClient()
spotify_client.authenticate()
app.logger.info('spotify auth successful')
from .registry import MusicProviderRegistry
MusicProviderRegistry.register_provider(spotify_client)
if app.config['ENABLE_DEEZER']:
from .providers import DeezerClient
deezer_client = DeezerClient()
deezer_client.authenticate()
MusicProviderRegistry.register_provider(deezer_client)
if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']:
app.logger.info(f'Creating Lidarr Client with URL: {app.config["LIDARR_URL"]}')
from lidarr.client import LidarrClient
lidarr_client = LidarrClient(app.config['LIDARR_URL'], app.config['LIDARR_API_KEY'])
if os.path.exists(yaml_file):
app.logger.info('Loading runtime settings from settings.yaml')
load_yaml_settings()
# def watch_yaml_file(yaml_file, interval=30):
# last_mtime = os.path.getmtime(yaml_file)
# while True:
# time.sleep(interval)
# current_mtime = os.path.getmtime(yaml_file)
# if current_mtime != last_mtime:
# last_mtime = current_mtime
# yaml_settings = load_yaml_settings(yaml_file)
# app.config.update(yaml_settings)
# app.logger.info(f"Reloaded YAML settings from {yaml_file}")
# watcher_thread = threading.Thread(
# target=watch_yaml_file,
# args=('settings.yaml',),
# daemon=True
# )
# watcher_thread.start()

116
app/classes.py Normal file
View File

@@ -0,0 +1,116 @@
from dataclasses import dataclass
from datetime import datetime
import subprocess
import json
from flask import current_app as app # Adjust this based on your app's structure
from typing import List, Optional
class AudioProfile:
def __init__(self, path: str, bitrate: int = 0, sample_rate: int = 0, channels: int = 0) -> None:
"""
Initialize an AudioProfile instance.
Args:
path (str): The file path of the audio file.
bitrate (int): The audio bitrate in kbps. Default is 0.
sample_rate (int): The sample rate in Hz. Default is 0.
channels (int): The number of audio channels. Default is 0.
"""
self.path: str = path
self.bitrate: int = bitrate # in kbps
self.sample_rate: int = sample_rate # in Hz
self.channels: int = channels
@staticmethod
def analyze_audio_quality_with_ffprobe(filepath: str) -> Optional['AudioProfile']:
"""
Static method to analyze audio quality using ffprobe and return an AudioProfile instance.
Args:
filepath (str): Path to the audio file to analyze.
Returns:
Optional[AudioProfile]: An instance of AudioProfile if analysis is successful, None otherwise.
"""
try:
# ffprobe command to extract bitrate, sample rate, and channel count
cmd = [
'ffprobe', '-v', 'error', '-select_streams', 'a:0',
'-show_entries', 'stream=bit_rate,sample_rate,channels',
'-show_format',
'-of', 'json', filepath
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
app.logger.error(f"ffprobe error for file {filepath}: {result.stderr}")
return None
# Parse ffprobe output
data = json.loads(result.stdout)
stream = data.get('streams', [{}])[0]
bitrate: int = int(stream.get('bit_rate', 0)) // 1000 # Convert to kbps
if bitrate == 0: # Fallback if no bit_rate in stream
bitrate = int(data.get('format').get('bit_rate', 0)) // 1000
sample_rate: int = int(stream.get('sample_rate', 0)) # Hz
channels: int = int(stream.get('channels', 0))
# Create an AudioProfile instance
return AudioProfile(filepath, bitrate, sample_rate, channels)
except Exception as e:
app.logger.error(f"Error analyzing audio quality with ffprobe: {str(e)}")
return None
def compute_quality_score(self) -> int:
"""
Compute a quality score based on bitrate, sample rate, and channels.
Returns:
int: The computed quality score.
"""
return self.bitrate + (self.sample_rate // 1000) + (self.channels * 10)
def __repr__(self) -> str:
"""
Representation of the AudioProfile instance.
Returns:
str: A string representation of the AudioProfile instance.
"""
return (f"AudioProfile(path='{self.path}', bitrate={self.bitrate} kbps, "
f"sample_rate={self.sample_rate} Hz, channels={self.channels})")
@dataclass
class CombinedTrackData():
# Combines a track from a provider with a track from the db
title: str
artist: List[str]
url: List[str]
duration: str
downloaded: bool
filesystem_path: Optional[str]
jellyfin_id: Optional[str]
provider_id: str
provider_track_id: str
duration_ms: int
download_status: Optional[str]
provider: str
@dataclass
class CombinedPlaylistData():
name: str
description: Optional[str]
image: str
url: str
id: str
jellyfin_id: Optional[str]
can_add: bool
can_remove: bool
last_updated: Optional[datetime]
last_changed: Optional[datetime]
tracks_available: int
track_count: int
tracks_linked: int
percent_available: float
status: str

117
app/filters.py Normal file
View File

@@ -0,0 +1,117 @@
import os
import re
from markupsafe import Markup
from app.classes import AudioProfile
from app import app, functions, read_dev_build_file
from .version import __version__
filters = {}
def template_filter(name):
"""Decorator to register a Jinja2 filter."""
def decorator(func):
filters[name] = func
return func
return decorator
@template_filter('highlight')
def highlight_search(text: str, search_query: str) -> Markup:
if not search_query:
return text
search_query_escaped = re.escape(search_query)
# If the text matches the search query exactly, apply a different highlight
if text.strip().lower() == search_query.strip().lower():
return Markup(f'<mark style="background-color: lightgreen; color: black;">{text}</mark>')
# Highlight partial matches in the text
highlighted_text = re.sub(
f"({search_query_escaped})",
r'<mark>\1</mark>',
text,
flags=re.IGNORECASE
)
return Markup(highlighted_text)
@template_filter('audioprofile')
def audioprofile(text: str, path: str) -> Markup:
if not path or not os.path.exists(path):
return Markup() # Return the original text if the file does not exist
# Create the AudioProfile instance using the static method
audio_profile = AudioProfile.analyze_audio_quality_with_ffprobe(path)
if not audio_profile:
return Markup(f"<span style='color: red;'>ERROR</span>")
# Create a nicely formatted HTML representation
audio_profile_html = (
f"<strong>Bitrate:</strong> {audio_profile.bitrate} kbps<br>"
f"<strong>Sample Rate:</strong> {audio_profile.sample_rate} Hz<br>"
f"<strong>Channels:</strong> {audio_profile.channels}<br>"
f"<strong>Quality Score:</strong> {audio_profile.compute_quality_score()}"
)
return Markup(audio_profile_html)
@template_filter('version_check')
def version_check(version: str) -> Markup:
version = f"{__version__}{read_dev_build_file()}"
# if version contains a dash and the text after the dash is LOCAL, return version as a blue badge
if app.config['CHECK_FOR_UPDATES']:
if '-' in version and version.split('-')[1] == 'LOCAL':
return Markup(f"<span class='badge rounded-pill bg-primary'>{version}</span>")
# else if the version string contains a dash and the text after the dash is not LOCAL, check whether it contains another dash (like in e.g. v0.1.7-dev-89a1bc2) and split both parts
elif '-' in version and version.split('-')[1] != 'LOCAL' :
branch, commit_sha = version.split('-')[1], version.split('-')[2]
nra,url = functions.get_latest_dev_releases(branch_name = branch, commit_sha = commit_sha)
if nra:
return Markup(f"<a href='{url}' target='_blank'><span class='badge rounded-pill text-bg-warning btn-pulsing' data-bs-toggle='tooltip' title='An update for the {branch} branch is available.'>{version}</span></a>")
else:
return Markup(f"<span class='badge rounded-pill text-bg-secondary'>{version}</span>")
else:
nra,url = functions.get_latest_release(version)
if nra:
return Markup(f"<a href='{url}' target='_blank'><span class='badge rounded-pill text-bg-warning btn-pulsing' data-bs-toggle='tooltip' title='An update is available.'>{version}</span></a>")
return Markup(f"<span class='badge rounded-pill text-bg-primary'>{version}</span>")
else:
return Markup(f"<span class='badge rounded-pill text-bg-info'>{version}</span>")
@template_filter('jellyfin_link')
def jellyfin_link(jellyfin_id: str) -> Markup:
jellyfin_server_url = app.config.get('JELLYFIN_SERVER_URL')
if not jellyfin_server_url:
return Markup(f"<span style='color: red;'>JELLYFIN_SERVER_URL not configured</span>")
link = f"{jellyfin_server_url}/web/#/details?id={jellyfin_id}"
return Markup(f'<a href="{link}" target="_blank">{jellyfin_id}</a>')
@template_filter('jellyfin_link_button')
def jellyfin_link_btn(jellyfin_id: str) -> Markup:
jellyfin_server_url = app.config.get('JELLYFIN_SERVER_URL')
if not jellyfin_server_url:
return Markup(f"<span style='color: red;'>JELLYFIN_SERVER_URL not configured</span>")
link = f"{jellyfin_server_url}/web/#/details?id={jellyfin_id}"
return Markup(f'<a href="{link}" class="btn btn-primary mt-2" target="_blank">Open in Jellyfin</a>')
# A template filter for displaying a datetime in a human-readable format
@template_filter('human_datetime')
def human_datetime(dt) -> str:
if not dt:
return 'No date provided'
return dt.strftime('%Y-%m-%d %H:%M:%S')

View File

@@ -1,130 +1,102 @@
from flask import flash, redirect, session, url_for
import json
from typing import List, Optional
from flask import flash, redirect, session, url_for,g
import requests
from app.classes import CombinedPlaylistData, CombinedTrackData
from app.models import JellyfinUser, Playlist,Track
from app import sp, cache, app, jellyfin ,jellyfin_admin_token, jellyfin_admin_id,device_id, cache
from app import sp, cache, app, jellyfin ,jellyfin_admin_token, jellyfin_admin_id,device_id, cache, redis_client
from functools import wraps
from celery.result import AsyncResult
from app.tasks import download_missing_tracks,check_for_playlist_updates, update_all_playlists_track_status, update_jellyfin_id_for_downloaded_tracks
from app.providers import base
from app.providers.base import PlaylistTrack
from app.registry.music_provider_registry import MusicProviderRegistry
from lidarr.classes import Album, Artist
from . import tasks
from jellyfin.objects import PlaylistMetadata
from spotipy.exceptions import SpotifyException
TASK_STATUS = {
'update_all_playlists_track_status': None,
'download_missing_tracks': None,
'check_for_playlist_updates': None,
'update_jellyfin_id_for_downloaded_tracks' : None
}
import re
def manage_task(task_name):
task_id = TASK_STATUS.get(task_name)
if task_id:
result = AsyncResult(task_id)
if result.state in ['PENDING', 'STARTED']:
return result.state, result.info if result.info else {}
if task_name == 'update_all_playlists_track_status':
result = update_all_playlists_track_status.delay()
elif task_name == 'download_missing_tracks':
result = download_missing_tracks.delay()
elif task_name == 'check_for_playlist_updates':
result = check_for_playlist_updates.delay()
elif task_name == 'update_jellyfin_id_for_downloaded_tracks':
result = update_jellyfin_id_for_downloaded_tracks.delay()
TASK_STATUS[task_name] = result.id
return result.state, result.info if result.info else {}
def prepPlaylistData(data):
playlists = []
def prepPlaylistData(playlist: base.Playlist) -> Optional[CombinedPlaylistData]:
jellyfin_user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
if not data.get('playlists'):
data['playlists']= {}
data['playlists']['items'] = [data]
for playlist_data in data['playlists']['items']:
# Fetch the playlist from the database if it exists
db_playlist = Playlist.query.filter_by(spotify_playlist_id=playlist_data['id']).first()
if db_playlist:
# If the playlist is in the database, use the stored values
if isinstance(playlist_data['tracks'],list):
track_count = len(playlist_data['tracks'] )
else:
track_count = playlist_data['tracks']['total'] or 0
tracks_available = db_playlist.tracks_available or 0
tracks_linked = len([track for track in db_playlist.tracks if track.jellyfin_id]) or 0
percent_available = (tracks_available / track_count * 100) if track_count > 0 else 0
# Determine playlist status
if tracks_available == track_count and track_count > 0:
status = 'green' # Fully available
elif tracks_available > 0:
status = 'yellow' # Partially available
else:
status = 'red' # Not available
else:
# If the playlist is not in the database, initialize with 0
track_count = 0
tracks_available = 0
tracks_linked = 0
percent_available = 0
status = 'red' # Not requested yet
# Append playlist data to the list
playlists.append({
'name': playlist_data['name'],
'description': playlist_data['description'],
'image': playlist_data['images'][0]['url'] if playlist_data['images'] else 'default-image.jpg',
'url': playlist_data['external_urls']['spotify'],
'id': playlist_data['id'],
'jellyfin_id': db_playlist.jellyfin_id if db_playlist else '',
'can_add': (db_playlist not in jellyfin_user.playlists) if db_playlist else True,
'can_remove' : (db_playlist in jellyfin_user.playlists) if db_playlist else False,
'last_updated':db_playlist.last_updated if db_playlist else '',
'last_changed':db_playlist.last_changed if db_playlist else '',
'tracks_available': tracks_available,
'track_count': track_count,
'tracks_linked': tracks_linked,
'percent_available': percent_available,
'status': status # Red, yellow, or green based on availability
})
return playlists
def get_cached_spotify_playlists(playlist_ids):
"""
Fetches multiple Spotify playlists by their IDs, utilizing individual caching.
:param playlist_ids: A list of Spotify playlist IDs.
:return: A dictionary containing the fetched playlists.
"""
spotify_data = {'playlists': {'items': []}}
for playlist_id in playlist_ids:
playlist_data = get_cached_spotify_playlist(playlist_id)
if playlist_data:
spotify_data['playlists']['items'].append(playlist_data)
else:
app.logger.warning(f"Playlist data for ID {playlist_id} could not be retrieved.")
return spotify_data
@cache.memoize(timeout=3600)
def get_cached_spotify_playlist(playlist_id):
"""
Fetches a Spotify playlist by its ID, utilizing caching to minimize API calls.
:param playlist_id: The Spotify playlist ID.
:return: Playlist data as a dictionary, or None if an error occurs.
"""
try:
playlist_data = sp.playlist(playlist_id) # Fetch data from Spotify API
return playlist_data
except Exception as e:
app.logger.error(f"Error fetching playlist {playlist_id} from Spotify: {str(e)}")
if not jellyfin_user:
app.logger.error(f"jellyfin_user not set: session user id: {session['jellyfin_user_id']}. Logout and Login again")
return None
@cache.memoize(timeout=3600*24*10)
def get_cached_spotify_track(track_id):
# Fetch the playlist from the database if it exists
db_playlist : Playlist = Playlist.query.filter_by(provider_playlist_id=playlist.id).first() if playlist else None
# Initialize default values
track_count = db_playlist.track_count if db_playlist else 0
tracks_available = db_playlist.tracks_available if db_playlist else 0
tracks_linked = len([track for track in db_playlist.tracks if track.jellyfin_id]) if db_playlist else 0
percent_available = (tracks_available / track_count * 100) if track_count > 0 else 0
# Determine playlist status
if tracks_available == track_count and track_count > 0:
status = 'green' # Fully available
elif tracks_available > 0:
status = 'yellow' # Partially available
else:
status = 'red' # Not available
# Build and return the PlaylistResponse object
return CombinedPlaylistData(
name=playlist.name,
description=playlist.description,
image=playlist.images[0].url if playlist.images else '/static/images/placeholder.png',
url=playlist.external_urls[0].url if playlist.external_urls else '',
id=playlist.id,
jellyfin_id=db_playlist.jellyfin_id if db_playlist else '',
can_add=(db_playlist not in jellyfin_user.playlists) if db_playlist else True,
can_remove=(db_playlist in jellyfin_user.playlists) if db_playlist else False,
last_updated=db_playlist.last_updated if db_playlist else None,
last_changed=db_playlist.last_changed if db_playlist else None,
tracks_available=tracks_available,
track_count=track_count,
tracks_linked=tracks_linked,
percent_available=percent_available,
status=status
)
def lidarr_quality_profile_id(profile_id=None):
if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']:
from app import lidarr_client
if profile_id:
redis_client.set('lidarr_quality_profile_id', profile_id)
else:
value = redis_client.get('lidarr_quality_profile_id')
if not value:
value = lidarr_client.get_quality_profiles()[0]
lidarr_quality_profile_id(value.id)
return value
return value
def lidarr_root_folder_path(folder_path=None):
if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']:
from app import lidarr_client
if folder_path:
redis_client.set('lidarr_root_folder_path', folder_path)
else:
value = redis_client.get('lidarr_root_folder_path')
if not value:
value = lidarr_client.get_root_folders()[0]
lidarr_root_folder_path(value.path)
return value.path
return value
# a function which takes a lidarr.class.Artist object as a parameter, and applies the lidarr_quality_profile_id to the artist if its 0
def apply_default_profile_and_root_folder(object : Artist ) -> Artist:
if object.qualityProfileId == 0:
object.qualityProfileId = int(lidarr_quality_profile_id())
if object.rootFolderPath == '' or object.rootFolderPath == None:
object.rootFolderPath = str(lidarr_root_folder_path())
if object.metadataProfileId == 0:
object.metadataProfileId = 1
return object
@cache.memoize(timeout=3600*24*10)
def get_cached_provider_track(track_id : str,provider_id : str)-> base.Track:
"""
Fetches a Spotify track by its ID, utilizing caching to minimize API calls.
@@ -132,80 +104,44 @@ def get_cached_spotify_track(track_id):
:return: Track data as a dictionary, or None if an error occurs.
"""
try:
track_data = sp.track(track_id=track_id) # Fetch data from Spotify API
# get the provider from the registry
provider = MusicProviderRegistry.get_provider(provider_id)
track_data = provider.get_track(track_id)
return track_data
except Exception as e:
app.logger.error(f"Error fetching track {track_id} from Spotify: {str(e)}")
app.logger.error(f"Error fetching track {track_id} from {provider_id}: {str(e)}")
return None
@cache.memoize(timeout=3600)
def get_cached_provider_playlist(playlist_id : str,provider_id : str)-> base.Playlist:
"""
Fetches a playlist by its ID, utilizing caching to minimize API calls.
def prepAlbumData(data):
items = []
for item in data['albums']['items']:
items.append({
'name': item['name'],
'description': f"Released: {item['release_date']}",
'image': item['images'][0]['url'] if item['images'] else 'default-image.jpg',
'url': item['external_urls']['spotify'],
'id' : item['id'],
'can_add' : False
})
return items
:param playlist_id: The playlist ID.
:return: Playlist data as a dictionary, or None if an error occurs.
"""
try:
# get the provider from the registry
provider = MusicProviderRegistry.get_provider(provider_id)
playlist_data = provider.get_playlist(playlist_id)
return playlist_data
except Exception as e:
app.logger.error(f"Error fetching playlist {playlist_id} from {provider_id}: {str(e)}")
return None
def prepArtistData(data):
items = []
for item in data['artists']['items']:
items.append({
'name': item['name'],
'description': f"Popularity: {item['popularity']}",
'image': item['images'][0]['url'] if item['images'] else 'default-image.jpg',
'url': item['external_urls']['spotify'],
'id' : item['id'],
'can_add' : False
})
return items
def getFeaturedPlaylists(country,offset):
playlists_data = sp.featured_playlists(country=country, limit=16, offset=offset)
return prepPlaylistData(playlists_data), playlists_data['playlists']['total'],'Featured Playlists'
def getCategoryPlaylists(category,offset):
playlists_data = sp.category_playlists(category_id=category, limit=16, offset=offset)
return prepPlaylistData(playlists_data), playlists_data['playlists']['total'],f"Category {playlists_data['message']}"
def getCategories(country,offset):
categories_data = sp.categories(limit=16, offset= offset)
categories = []
for cat in categories_data['categories']['items']:
categories.append({
'name': cat['name'],
'description': '',
'image': cat['icons'][0]['url'] if cat['icons'] else 'default-image.jpg',
'url': f"/playlists?cat={cat['id']}",
'id' : cat['id'],
'type':'category'
})
return categories, categories_data['categories']['total'],'Browse Categories'
def get_tracks_for_playlist(data):
results = data
tracks = []
def get_tracks_for_playlist(data: List[PlaylistTrack], provider_id : str ) -> List[CombinedTrackData]:
is_admin = session.get('is_admin', False)
tracks = []
for idx, item in enumerate(results['tracks']):
track_data = item['track']
for idx, item in enumerate(data):
track_data = item.track
if track_data:
duration_ms = track_data['duration_ms']
duration_ms = track_data.duration_ms
minutes = duration_ms // 60000
seconds = (duration_ms % 60000) // 1000
track_db = Track.query.filter_by(spotify_track_id=track_data['id']).first()
# Query track from the database
track_db = Track.query.filter_by(provider_track_id=track_data.id).first()
if track_db:
downloaded = track_db.downloaded
@@ -218,40 +154,26 @@ def get_tracks_for_playlist(data):
jellyfin_id = None
download_status = None
tracks.append({
'title': track_data['name'],
'artist': ', '.join([artist['name'] for artist in track_data['artists']]),
'url': track_data['external_urls']['spotify'],
'duration': f'{minutes}:{seconds:02d}',
'preview_url': track_data['preview_url'],
'downloaded': downloaded,
'filesystem_path': filesystem_path,
'jellyfin_id': jellyfin_id,
'spotify_id': track_data['id'],
'duration_ms': duration_ms,
'download_status' : download_status
})
# Append a TrackResponse object
tracks.append(
CombinedTrackData(
title=track_data.name,
artist=[a.name for a in track_data.artists],
url=[url.url for url in track_data.external_urls],
duration=f'{minutes}:{seconds:02d}',
downloaded=downloaded,
filesystem_path=filesystem_path,
jellyfin_id=jellyfin_id,
provider_track_id=track_data.id,
provider_id = provider_id,
duration_ms=duration_ms,
download_status=download_status,
provider=provider_id
)
)
return tracks
def get_full_playlist_data(playlist_id):
playlist_data = get_cached_spotify_playlist(playlist_id)
all_tracks = []
offset = 0
while True:
response = sp.playlist_items(playlist_id, offset=offset, limit=100)
items = response['items']
all_tracks.extend(items)
if len(items) < 100:
break
offset += 100
playlist_data['tracks'] = all_tracks
playlist_data['prepped_data'] = prepPlaylistData(playlist_data)
return playlist_data
def jellyfin_login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
@@ -273,13 +195,13 @@ def jellyfin_admin_required(f):
def update_playlist_metadata(playlist,spotify_playlist_data):
def update_playlist_metadata(playlist,provider_playlist_data : base.Playlist):
metadata = PlaylistMetadata()
metadata.Tags = [f'jellyplist:playlist:{playlist.id}',f'{playlist.tracks_available} of {playlist.track_count} Tracks available']
metadata.Overview = spotify_playlist_data['description']
metadata.Overview = provider_playlist_data.description
jellyfin.update_playlist_metadata(session_token=_get_api_token(),playlist_id=playlist.jellyfin_id,updates= metadata , user_id= _get_admin_id())
if spotify_playlist_data['images'] != None:
jellyfin.set_playlist_cover_image(session_token= _get_api_token(),playlist_id= playlist.jellyfin_id,spotify_image_url= spotify_playlist_data['images'][0]['url'])
if provider_playlist_data.images:
jellyfin.set_playlist_cover_image(session_token= _get_api_token(),playlist_id= playlist.jellyfin_id,provider_image_url= provider_playlist_data.images[0].url)
@@ -288,8 +210,58 @@ def _get_token_from_sessioncookie() -> str:
def _get_api_token() -> str:
#return app.config['JELLYFIN_ACCESS_TOKEN']
return jellyfin_admin_token
def _get_logged_in_user():
def _get_logged_in_user() -> JellyfinUser:
return JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
def _get_admin_id():
#return JellyfinUser.query.filter_by(is_admin=True).first().jellyfin_user_id
return jellyfin_admin_id
return jellyfin_admin_id
def get_longest_substring(input_string):
special_chars = ["'", "", "", "", "`", "´", ""]
pattern = "[" + re.escape("".join(special_chars)) + "]"
substrings = re.split(pattern, input_string)
longest_substring = max(substrings, key=len, default="")
return longest_substring
@cache.memoize(timeout=3600*2)
def get_latest_dev_releases(branch_name :str, commit_sha : str):
try:
response = requests.get('https://api.github.com/repos/kamilkosek/jellyplist/releases')
if response.status_code == 200:
data = response.json()
latest_release = None
for release in data:
if branch_name in release['tag_name']:
if latest_release is None or release['published_at'] > latest_release['published_at']:
latest_release = release
if latest_release:
response = requests.get(f'https://api.github.com/repos/kamilkosek/jellyplist/git/ref/tags/{latest_release["tag_name"]}')
if response.status_code == 200:
data = response.json()
if commit_sha != data['object']['sha'][:7]:
return True, latest_release['html_url']
return False, ''
except requests.exceptions.RequestException as e:
app.logger.error(f"Error fetching latest version: {str(e)}")
return False, ''
@cache.memoize(timeout=3600*2)
def get_latest_release(tag_name :str):
try:
response = requests.get('https://api.github.com/repos/kamilkosek/jellyplist/releases/latest')
if response.status_code == 200:
data = response.json()
if data['tag_name'] != tag_name:
return True, data['html_url']
return False, ''
except requests.exceptions.RequestException as e:
app.logger.error(f"Error fetching latest version: {str(e)}")
return False,''
def set_log_level(level):
app.logger.setLevel(level)
app.logger.info(f"Log level set to {level}")

View File

@@ -1,176 +0,0 @@
import time
from flask import Flask, jsonify, render_template, request, redirect, url_for, session, flash
from sqlalchemy import insert
from app import app, db, jellyfin, functions, device_id
from app.models import Playlist,Track, playlist_tracks
from jellyfin.objects import PlaylistMetadata
@app.route('/jellyfin_playlists')
@functions.jellyfin_login_required
def jellyfin_playlists():
try:
# Fetch playlists from Jellyfin
playlists = jellyfin.get_playlists(session_token=functions._get_token_from_sessioncookie())
# Extract Spotify playlist IDs from the database
spotify_playlist_ids = []
for pl in playlists:
# Retrieve the playlist from the database using Jellyfin ID
from_db = Playlist.query.filter_by(jellyfin_id=pl['Id']).first()
if from_db and from_db.spotify_playlist_id:
spotify_playlist_ids.append(from_db.spotify_playlist_id)
else:
app.logger.warning(f"No database entry found for Jellyfin playlist ID: {pl['Id']}")
if not spotify_playlist_ids:
flash('No Spotify playlists found to display.', 'warning')
return render_template('jellyfin_playlists.html', playlists=functions.prepPlaylistData({'playlists': {'items': []}}))
# Use the cached function to fetch Spotify playlists
spotify_data = functions.get_cached_spotify_playlists(spotify_playlist_ids)
# Prepare the data for the template
prepared_data = functions.prepPlaylistData(spotify_data)
return render_template('jellyfin_playlists.html', playlists=prepared_data)
except Exception as e:
app.logger.error(f"Error in /jellyfin_playlists route: {str(e)}")
flash('An error occurred while fetching playlists.', 'danger')
return render_template('jellyfin_playlists.html', playlists=functions.prepPlaylistData({'playlists': {'items': []}}))
@app.route('/addplaylist', methods=['POST'])
@functions.jellyfin_login_required
def add_playlist():
playlist_id = request.form.get('item_id') # HTMX sends the form data
playlist_name = request.form.get('item_name') # Optionally retrieve playlist name from the form
if not playlist_id:
flash('No playlist ID provided')
return ''
try:
# Fetch playlist from Spotify API (or any relevant API)
playlist_data = functions.get_cached_spotify_playlist(playlist_id)
# Check if playlist already exists in the database
playlist = Playlist.query.filter_by(spotify_playlist_id=playlist_id).first()
if not playlist:
# Add new playlist if it doesn't exist
# create the playlist via api key, with the first admin as 'owner'
fromJellyfin = jellyfin.create_music_playlist(functions._get_api_token(),playlist_data['name'],[],functions._get_admin_id())['Id']
playlist = Playlist(name=playlist_data['name'], spotify_playlist_id=playlist_id,spotify_uri=playlist_data['uri'],track_count = playlist_data['tracks']['total'], tracks_available=0, jellyfin_id = fromJellyfin)
db.session.add(playlist)
db.session.commit()
if app.config['START_DOWNLOAD_AFTER_PLAYLIST_ADD']:
functions.manage_task('download_missing_tracks')
# Get the logged-in user
user = functions._get_logged_in_user()
playlist.tracks_available = 0
# Add tracks to the playlist with track order
for idx, track_data in enumerate(playlist_data['tracks']['items']):
track_info = track_data['track']
if not track_info:
continue
track = Track.query.filter_by(spotify_track_id=track_info['id']).first()
if not track:
# Add new track if it doesn't exist
track = Track(name=track_info['name'], spotify_track_id=track_info['id'], spotify_uri=track_info['uri'], downloaded=False)
db.session.add(track)
db.session.commit()
elif track.downloaded:
playlist.tracks_available += 1
db.session.commit()
# Add track to playlist with order if it's not already associated
if track not in playlist.tracks:
# Insert into playlist_tracks with track order
stmt = insert(playlist_tracks).values(
playlist_id=playlist.id,
track_id=track.id,
track_order=idx # Maintain the order of tracks
)
db.session.execute(stmt)
db.session.commit()
update_playlist_metadata(playlist,playlist_data)
if playlist not in user.playlists:
user.playlists.append(playlist)
db.session.commit()
jellyfin.add_users_to_playlist(session_token=functions._get_api_token(), user_id=functions._get_admin_id(),playlist_id = playlist.jellyfin_id,user_ids= [user.jellyfin_user_id])
flash(f'Playlist "{playlist_data["name"]}" successfully added','success')
else:
flash(f'Playlist "{playlist_data["name"]}" already in your list')
item = {
"name" : playlist_data["name"],
"id" : playlist_id,
"can_add":False,
"can_remove":True,
"jellyfin_id" : playlist.jellyfin_id
}
return render_template('partials/_add_remove_button.html',item= item)
except Exception as e:
flash(str(e))
@app.route('/delete_playlist/<playlist_id>', methods=['DELETE'])
@functions.jellyfin_login_required
def delete_playlist(playlist_id):
# Logic to delete the playlist using JellyfinClient
try:
user = functions._get_logged_in_user()
for pl in user.playlists:
if pl.jellyfin_id == playlist_id:
user.playlists.remove(pl)
playlist = pl
jellyfin.remove_user_from_playlist(session_token= functions._get_api_token(), playlist_id= playlist_id, user_id=user.jellyfin_user_id)
db.session.commit()
flash('Playlist removed')
item = {
"name" : playlist.name,
"id" : playlist.spotify_playlist_id,
"can_add":True,
"can_remove":False,
"jellyfin_id" : playlist.jellyfin_id
}
return render_template('partials/_add_remove_button.html',item= item)
except Exception as e:
flash(f'Failed to remove item: {str(e)}')
@functions.jellyfin_login_required
@app.route('/get_jellyfin_stream/<string:jellyfin_id>')
def get_jellyfin_stream(jellyfin_id):
user_id = session['jellyfin_user_id'] # Beispiel: dynamischer Benutzer
api_key = functions._get_token_from_sessioncookie() # Beispiel: dynamischer API-Schlüssel
stream_url = f"{app.config['JELLYFIN_SERVER_URL']}/Audio/{jellyfin_id}/universal?UserId={user_id}&DeviceId={device_id}&MaxStreamingBitrate=140000000&Container=opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg&TranscodingContainer=mp4&TranscodingProtocol=hls&AudioCodec=aac&api_key={api_key}&PlaySessionId={int(time.time())}&StartTimeTicks=0&EnableRedirection=true&EnableRemoteMedia=false"
return jsonify({'stream_url': stream_url})
@app.route('/search_jellyfin', methods=['GET'])
@functions.jellyfin_login_required
def search_jellyfin():
search_query = request.args.get('search_query')
spotify_id = request.args.get('spotify_id')
if search_query:
results = jellyfin.search_music_tracks(functions._get_token_from_sessioncookie(), search_query)
# Render only the search results section as response
return render_template('partials/_jf_search_results.html', results=results,spotify_id= spotify_id)
return jsonify({'error': 'No search query provided'}), 400

View File

@@ -22,8 +22,8 @@ user_playlists = db.Table('user_playlists',
class Playlist(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), nullable=False)
spotify_playlist_id = db.Column(db.String(120), unique=True, nullable=False)
spotify_uri = db.Column(db.String(120), unique=True, nullable=False)
provider_playlist_id = db.Column(db.String(120), unique=True, nullable=False)
provider_uri = db.Column(db.String(120), unique=True, nullable=False)
# Relationship with Tracks
tracks = db.relationship('Track', secondary='playlist_tracks', back_populates='playlists')
@@ -35,9 +35,10 @@ class Playlist(db.Model):
snapshot_id = db.Column(db.String(120), nullable=True)
# Many-to-Many relationship with JellyfinUser
users = db.relationship('JellyfinUser', secondary=user_playlists, back_populates='playlists')
provider_id = db.Column(db.String(20))
def __repr__(self):
return f'<Playlist {self.name}:{self.spotify_playlist_id}>'
return f'<Playlist {self.name}:{self.provider_playlist_id}>'
# Association table between Playlists and Tracks
playlist_tracks = db.Table('playlist_tracks',
@@ -49,15 +50,20 @@ playlist_tracks = db.Table('playlist_tracks',
class Track(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), nullable=False)
spotify_track_id = db.Column(db.String(120), unique=True, nullable=False)
spotify_uri = db.Column(db.String(120), unique=True, nullable=False)
name = db.Column(db.String(200), nullable=False)
provider_track_id = db.Column(db.String(120), unique=True, nullable=False)
provider_uri = db.Column(db.String(120), unique=True, nullable=False)
downloaded = db.Column(db.Boolean())
filesystem_path = db.Column(db.String(), nullable=True)
jellyfin_id = db.Column(db.String(120), nullable=True) # Add Jellyfin track ID field
download_status = db.Column(db.String(2048), nullable=True)
provider_id = db.Column(db.String(20))
# Many-to-Many relationship with Playlists
playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks')
lidarr_processed = db.Column(db.Boolean(), default=False)
quality_score = db.Column(db.Float(), default=0)
def __repr__(self):
return f'<Track {self.name}:{self.spotify_track_id}>'
return f'<Track {self.name}:{self.provider_track_id}>'

View File

@@ -0,0 +1,4 @@
from .spotify import SpotifyClient
#from .deezer import DeezerClient
__all__ = ["SpotifyClient"]

173
app/providers/base.py Normal file
View File

@@ -0,0 +1,173 @@
from dataclasses import dataclass, field
from typing import List, Optional
from abc import ABC, abstractmethod
@dataclass
class ExternalUrl:
url: str
@dataclass
class ItemBase:
id: str
name: str
uri: str
external_urls: Optional[List[ExternalUrl]]
@dataclass
class Profile:
avatar: Optional[str] # Avatar URL or None
avatar_background_color: Optional[int]
name: str
uri: str
username: str
@dataclass
class AccountAttributes:
catalogue: str
dsa_mode_available: bool
dsa_mode_enabled: bool
multi_user_plan_current_size: Optional[int]
multi_user_plan_member_type: Optional[str]
on_demand: bool
opt_in_trial_premium_only_market: bool
country: str
product: str
@dataclass
class Image:
url: str
height: Optional[int]
width: Optional[int]
@dataclass
class Artist(ItemBase):
pass
@dataclass
class Album(ItemBase):
artists: List[Artist]
images: List[Image]
@dataclass
class Track(ItemBase):
duration_ms: int
explicit: Optional[bool]
album: Optional[Album]
artists: List[Artist]
@dataclass
class PlaylistTrack:
added_at: Optional[str]
added_by: Optional[str]
is_local: bool
track: Track
@dataclass
class Owner(ItemBase):
pass
@dataclass #tbc
class Category(ItemBase):
pass
@dataclass
class Playlist(ItemBase):
description: Optional[str]
public: Optional[bool]
collaborative: Optional[bool]
followers: Optional[int]
images: Optional[List[Image]]
owner: Optional[Owner]
tracks: List[PlaylistTrack] = field(default_factory=list)
@dataclass
class BrowseCard:
title: str
uri: str
background_color: str
artwork: List[Image]
@dataclass
class BrowseSection:
title: str
items: List[BrowseCard]
uri: str
# Abstract base class for music providers
class MusicProviderClient(ABC):
"""
Abstract base class defining the interface for music provider clients.
"""
@property
@abstractmethod
def _identifier(self) -> str:
"""
A unique identifier for the music provider.
Must be implemented by all subclasses.
"""
pass
@abstractmethod
def authenticate(self, credentials: dict) -> None:
"""
Authenticates the client with the provider using the provided credentials.
:param credentials: A dictionary containing credentials (e.g., API keys, tokens).
"""
pass
@abstractmethod
def get_playlist(self, playlist_id: str) -> Playlist:
"""
Fetches a playlist by its ID.
:param playlist_id: The ID of the playlist to fetch.
:return: A Playlist object.
"""
pass
@abstractmethod
def extract_playlist_id(self, uri: str) -> str:
"""
Extracts the playlist ID from a playlist URI.
:param uri: The playlist URI.
:return: The playlist ID.
"""
pass
@abstractmethod
def search_playlist(self, query: str, limit: int = 50) -> List[Playlist]:
"""
Searches for tracks based on a query string.
:param query: The search query.
:param limit: Maximum number of results to return.
:return: A list of Track objects.
"""
pass
@abstractmethod
def get_track(self, track_id: str) -> Track:
"""
Fetches details for a specific track.
:param track_id: The ID of the track to fetch.
:return: A Track object.
"""
pass
@abstractmethod
def browse(self, **kwargs) -> List[BrowseSection]:
"""
Generic browse method for the music provider.
:param kwargs: Variable keyword arguments to support different browse parameters
:return: A dictionary containing browse results
"""
pass
@abstractmethod
def browse_page(self, uri: str) -> List[Playlist]:
"""
Fetches a specific page of browse results.
:param uri: The uri to query.
:return: A list of Playlist objects.
"""
pass

320
app/providers/deezer.py Normal file
View File

@@ -0,0 +1,320 @@
import time
from bs4 import BeautifulSoup
import deezer
import deezer.resources
import deezer.exceptions
import json
import requests
from typing import List, Optional, Dict
import logging
from deezer import Client
from app.providers.base import (
MusicProviderClient,
AccountAttributes,
Album,
Artist,
BrowseCard,
BrowseSection,
Image,
Owner,
Playlist,
PlaylistTrack,
Profile,
Track,
ExternalUrl,
Category,
)
l = logging.getLogger(__name__)
class DeezerClient(MusicProviderClient):
"""
Deezer implementation of the MusicProviderClient.
An abstraction layer of deezer-python
https://github.com/browniebroke/deezer-python library to work with Jellyplist.
"""
@property
def _identifier(self) -> str:
return "Deezer"
def __init__(self, access_token: Optional[str] = None):
"""
Initialize the Deezer client.
:param access_token: Optional access token for authentication.
"""
self._client = deezer.Client(access_token=access_token)
#region Helper methods for parsing Deezer API responses
def _parse_track(self, track: deezer.resources.Track) -> Track:
"""
Parse a track object.
:param track: The track object from the Deezer API.
:return: A Track object.
"""
l.debug(f"Track: {track}")
retrycount= 0
max_retries = 3
wait = .8
while True:
try:
artists = [self._parse_artist(track.artist)]
if hasattr(track, 'contributors'):
artists = [self._parse_artist(artist) for artist in track.contributors]
return Track(
id=str(track.id),
name=track.title,
uri=f"deezer:track:{track.id}",
duration_ms=track.duration * 1000,
explicit=track.explicit_lyrics,
album=self._parse_album(track.album),
artists=artists,
external_urls=[],
)
except deezer.exceptions.DeezerErrorResponse as e:
if e.json_data['error']['code'] == 4:
l.warning(f"Quota limit exceeded. Waiting for {wait} seconds before retrying...")
retrycount += 1
if retrycount >= max_retries:
l.error("Maximum retries reached. Aborting.")
raise
time.sleep(wait)
else:
raise
def _parse_artist(self, artist: deezer.resources.Artist) -> Artist:
"""
Parse an artist object.
:param artist: The artist object from the Deezer API.
:return: An Artist object.
"""
return Artist(
id=str(artist.id),
name=artist.name,
uri=f"deezer:artist:{artist.id}",
external_urls=[],
)
def _parse_album(self, album: deezer.resources.Album) -> Album:
"""
Parse an album object.
:param album: The album object from the Deezer API.
:return: An Album object.
"""
#artists = [self._parse_artist(artist) for artist in album.contributors]
artists = []
images = [Image(url=album.cover_xl, height=None, width=None)]
return Album(
id=str(album.id),
name=album.title,
uri=f"deezer:album:{album.id}",
external_urls=[],
artists=artists,
images=images
)
def _parse_playlist(self, playlist: deezer.resources.Playlist) -> Playlist:
"""
Parse a playlist object.
:param playlist: The playlist object from the Deezer API.
:return: A Playlist object.
"""
images = [Image(url=playlist.picture_medium, height=None, width=None)]
tracks = []
tracks = [PlaylistTrack(is_local=False, track=self._parse_track(playlist_track), added_at='', added_by='') for playlist_track in playlist.get_tracks()]
return Playlist(
id=str(playlist.id),
name=playlist.title,
uri=f"deezer:playlist:{playlist.id}",
external_urls=[ExternalUrl(url=playlist.link)],
description=playlist.description,
public=playlist.public,
collaborative=playlist.collaborative,
followers=playlist.fans,
images=images,
owner=Owner(
id=str(playlist.creator.id),
name=playlist.creator.name,
uri=f"deezer:user:{playlist.creator.id}",
external_urls=[ExternalUrl(url=playlist.creator.link)]
),
tracks=tracks
)
#endregion
def authenticate(self, credentials: Optional[dict] = None) -> None:
"""
Authenticate with Deezer using an access token.
:param credentials: Optional dictionary containing 'access_token'.
"""
l.info("Authentication is handled by deezer-python.")
pass
def extract_playlist_id(self, uri: str) -> str:
"""
Extract the playlist ID from a Deezer playlist URL or URI.
:param uri: The playlist URL or URI.
:return: The playlist ID.
"""
# TODO: Implement this method
return ''
def get_playlist(self, playlist_id: str) -> Playlist:
"""
Fetch a playlist by its ID.
:param playlist_id: The ID of the playlist to fetch.
:return: A Playlist object.
"""
data = self._client.get_playlist(int(playlist_id))
return self._parse_playlist(data)
def search_playlist(self, query: str, limit: int = 50) -> List[Playlist]:
"""
Search for playlists matching a query.
:param query: The search query.
:param limit: Maximum number of results to return.
:return: A list of Playlist objects.
"""
playlists = []
search_results = self._client.search_playlists(query, strict=None, ordering=None)
for item in search_results:
images = [Image(url=item.picture_xl, height=None, width=None)]
tracks = [PlaylistTrack(is_local=False, track=self._parse_track(playlist_track), added_at='', added_by='') for playlist_track in item.tracks]
playlist = Playlist(
id=str(item.id),
name=item.title,
uri=f"deezer:playlist:{item.id}",
external_urls=[ExternalUrl(url=item.link)],
description=item.description,
public=item.public,
collaborative=item.collaborative,
followers=item.fans,
images=images,
owner=Owner(
id=str(item.creator.id),
name=item.create.name,
uri=f"deezer:user:{item.creator.id}",
external_urls=[ExternalUrl(url=item.creator.link)]
),
tracks=tracks
)
playlists.append(playlist)
return playlists
def get_track(self, track_id: str) -> Track:
"""
Fetch a track by its ID.
:param track_id: The ID of the track to fetch.
:return: A Track object.
"""
track = self._client.get_track(int(track_id))
return self._parse_track(track)
def browse(self, **kwargs) -> List[BrowseSection]:
"""
Browse featured content.
:param kwargs: Additional parameters.
:return: A list of BrowseSection objects.
"""
# Deezer does not have a direct equivalent, but we can fetch charts
url = 'https://www.deezer.com/de/channels/explore/explore-tab'
headers = {
'Upgrade-Insecure-Requests': '1',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0',
'sec-ch-ua': '"Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
'sec-ch-ua-mobile': '?0'
}
response = requests.get(url, headers=headers)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
dzr_app_div = soup.find('div', id='dzr-app')
script_tag = dzr_app_div.find('script')
script_content = script_tag.string.strip()
json_content = script_content.replace('window.__DZR_APP_STATE__ = ', '', 1)
data = json.loads(json_content)
sections = []
for section in data['sections']:
browse_section = None
if 'module_type=channel' in section['section_id']:
cards = []
for item in section['items']:
if item['type'] == 'channel':
image_url = f"https://cdn-images.dzcdn.net/images/{item['image_linked_item']['type']}/{item['image_linked_item']['md5']}/256x256-000000-80-0-0.jpg"
card = BrowseCard(
title=item['title'],
uri=f"deezer:channel:{item['data']['slug']}",
artwork=[Image(url=image_url, height=None, width=None)],
background_color=item['data']['background_color']
)
cards.append(card)
browse_section = BrowseSection(
title=section['title'],
uri=f"deezer:section:{section['group_id']}",
items=cards
)
if browse_section:
sections.append(browse_section)
return sections
def browse_page(self, uri: str) -> List[Playlist]:
"""
Fetch playlists for a given browse page.
:param uri: The uri to query.
:return: A list of Playlist objects.
"""
# Deezer does not have a direct equivalent, but we can fetch charts
playlists = []
slug = uri.split(':')[-1]
url = f'https://www.deezer.com/de/channels/{slug}'
headers = {
'Upgrade-Insecure-Requests': '1',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0',
'sec-ch-ua': '"Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
'sec-ch-ua-mobile': '?0'
}
response = requests.get(url, headers=headers)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
dzr_app_div = soup.find('div', id='dzr-app')
script_tag = dzr_app_div.find('script')
script_content = script_tag.string.strip()
json_content = script_content.replace('window.__DZR_APP_STATE__ = ', '', 1)
data = json.loads(json_content)
for section in data['sections']:
for item in section['items']:
if item['type'] == 'playlist':
#playlist = self.get_playlist(item['data']['slug'])
image_url = f"https://cdn-images.dzcdn.net/images/{item['type']}/{item['data']['PLAYLIST_PICTURE']}/256x256-000000-80-0-0.jpg"
playlist = Playlist(
id=str(item['id']),
name=item['title'],
uri=f"deezer:playlist:{item['id']}",
external_urls=[ExternalUrl(url=f"https://www.deezer.com/playlist/{item['target']}")],
description=item.get('catption',''),
public=True, # TODO: Check if this is correct
collaborative=False, # TODO: Check if this is correct
followers=item['data']['NB_FAN'],
images=[Image(url=image_url, height=None, width=None)],
owner=Owner(
id=item['data'].get('PARENT_USERNAME',''),
name=item['data'].get('PARENT_USERNAME',''),
uri=f"deezer:user:{item['data'].get('PARENT_USERNAME','')}",
external_urls=[ExternalUrl(url='')]
)
)
playlists.append(playlist)
return playlists

708
app/providers/spotify.py Normal file
View File

@@ -0,0 +1,708 @@
import os
from app.providers.base import AccountAttributes, Album, Artist, BrowseCard, BrowseSection, Image, MusicProviderClient, Owner, Playlist, PlaylistTrack, Profile, Track, ExternalUrl, Category
import base64
import json
import requests
from bs4 import BeautifulSoup
from urllib.parse import urlencode
from typing import List, Dict, Optional
from http.cookiejar import MozillaCookieJar
import logging
from typing import Callable, Tuple
import hmac
import time
import hashlib
l = logging.getLogger(__name__)
_TOTP_SECRET = bytearray([53,53,48,55,49,52,53,56,53,51,52,56,55,52,57,57,53,57,50,50,52,56,54,51,48,51,50,57,51,52,55])
def generate_totp(
secret: bytes = _TOTP_SECRET,
algorithm: Callable[[], object] = hashlib.sha1,
digits: int = 6,
counter_factory: Callable[[], int] = lambda: int(time.time()) // 30,
) -> Tuple[str, int]:
counter = counter_factory()
hmac_result = hmac.new(
secret, counter.to_bytes(8, byteorder="big"), algorithm # type: ignore
).digest()
offset = hmac_result[-1] & 15
truncated_value = (
(hmac_result[offset] & 127) << 24
| (hmac_result[offset + 1] & 255) << 16
| (hmac_result[offset + 2] & 255) << 8
| (hmac_result[offset + 3] & 255)
)
return (
str(truncated_value % (10**digits)).zfill(digits),
counter * 30_000,
) # (30 * 1000)
class SpotifyClient(MusicProviderClient):
"""
Spotify implementation of the MusicProviderClient.
"""
@property
def _identifier(self) -> str:
return "Spotify"
def __init__(self, cookie_file: Optional[str] = None):
self.base_url = "https://api-partner.spotify.com"
self.app_server_config_data = None
self.client_token = None
self.access_token = None
self.client_id = None
self.cookies = None
if cookie_file:
self._load_cookies(cookie_file)
def _load_cookies(self, cookie_file: str) -> None:
"""
Load cookies from a file.
:param cookie_file: Path to the cookie file.
"""
if not os.path.exists(cookie_file):
l.error(f"Cookie file not found: {cookie_file}")
raise FileNotFoundError(f"Cookie file not found: {cookie_file}")
cookie_jar = MozillaCookieJar(cookie_file)
cookie_jar.load(ignore_discard=True, ignore_expires=True)
self.cookies = requests.utils.dict_from_cookiejar(cookie_jar)
def authenticate(self, credentials: Optional[dict] = None) -> None:
"""
Authenticate with Spotify using cookies if available, or fetch session and config data.
:param credentials: Optional dictionary of credentials.
"""
if self.cookies:
l.debug("Authenticating using cookies.")
self.app_server_config_data = self._fetch_app_server_config_data()
else:
l.debug("Authenticating without cookies.")
self.app_server_config_data = self._fetch_app_server_config_data(fetch_with_cookies=False)
self._get_access_token_and_client_id()
self.client_token = self._fetch_client_token()
def _fetch_app_server_config_data(self, fetch_with_cookies: bool = True):
"""
Fetch app_server_config data from Spotify.
We will use the correlationId from app_server_config_data for a successful _fetch_client_token call
:param fetch_with_cookies: Whether to include cookies in the request.
:return: Tuple containing session and config data.
"""
url = 'https://open.spotify.com/'
headers = {
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
}
cookies = self.cookies if fetch_with_cookies else None
response = requests.get(url, headers=headers, cookies=cookies)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
app_server_config_script = soup.find('script', {'id': 'appServerConfig'}).text # decode JWT to obtain correlation required for obtain Bearer Token
decoded_app_server_config_script = base64.b64decode(app_server_config_script) # base64 decode
decoded_app_server_config_script = decoded_app_server_config_script.decode().strip("b'") # decode from byte object to string object
if decoded_app_server_config_script:
l.debug("fetched app_server_config_script scripts")
return json.loads(decoded_app_server_config_script)
else:
raise ValueError("Failed to fetch app_server_config data.")
def _fetch_client_token(self):
"""
Fetch the client token using session data and cookies.
:return: The client token as a string.
"""
url = f'https://clienttoken.spotify.com/v1/clienttoken'
headers = {
'accept': 'application/json',
'content-type': 'application/json',
'origin': 'https://open.spotify.com',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
}
payload = {
"client_data": {
"client_version": "1.2.52.404.gcb99a997",
"client_id": self.client_id,
"js_sdk_data": {
"device_brand": "unknown",
"device_model": "unknown",
"os": "windows",
"os_version": "NT 10.0",
"device_id": self.app_server_config_data.get("correlationId", ""),
"device_type": "computer"
}
}
}
response = requests.post(url, headers=headers, json=payload, cookies=self.cookies)
response.raise_for_status()
l.debug("fetched client_token (granted_token)")
return response.json().get("granted_token", "")
def _get_access_token_and_client_id(self, fetch_with_cookies: bool = True):
"""
Fetch the Access Token and Client ID by making GET call to open.spotify.com/get_access_token
"""
url = f'https://open.spotify.com/get_access_token'
headers = {
'accept': 'application/json',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
}
totp, timestamp = generate_totp()
query_params = {
"reason": "init",
"productType": "web-player",
"totp": totp,
"totpVer": 5,
"ts": timestamp,
}
cookies = self.cookies if fetch_with_cookies else None
response = requests.get(url, params=query_params, headers=headers, cookies=self.cookies)
response.raise_for_status()
self.client_id = response.json().get("clientId", "")
self.access_token = response.json().get("accessToken", "")
l.debug("fetched access_token and client_id")
def _make_request(self, endpoint: str, params: dict = None) -> dict:
"""
Helper method to make authenticated requests to Spotify APIs.
"""
headers = {
'accept': 'application/json',
'app-platform': 'WebPlayer',
'authorization': f'Bearer {self.access_token}',
'client-token': self.client_token.get('token','')
}
l.debug(f"starting request: {self.base_url}/{endpoint}")
response = requests.get(f"{self.base_url}/{endpoint}", headers=headers, params=params, cookies=self.cookies)
# if the response is unauthorized, we need to reauthenticate
if response.status_code == 401:
l.debug("reauthenticating")
self.authenticate()
headers['authorization'] = f'Bearer {self.access_token}'
headers['client-token'] = self.client_token.get('token','')
response = requests.get(f"{self.base_url}/{endpoint}", headers=headers, params=params, cookies=self.cookies)
response.raise_for_status()
return response.json()
#region utility functions to help parsing objects
def _parse_external_urls(self, uri: str, entity_type: str) -> List[ExternalUrl]:
"""
Create ExternalUrl instances for an entity.
:param uri: The URI of the entity.
:param entity_type: The type of entity ('track', 'album', 'artist', 'playlist', etc.).
:return: A list of ExternalUrl instances.
"""
return [ExternalUrl(url=f"https://open.spotify.com/{entity_type}/{uri.split(':')[-1]}")]
def _parse_images(self, image_data: List[Dict]) -> List[Image]:
"""
Parse images from the API response.
:param image_data: List of dictionaries containing image data.
:return: A list of Image objects.
"""
images = []
for img in image_data:
# Extract the first source if available
sources = img.get("sources", [])
if sources:
source = sources[0] # Take the first source as the default
images.append(Image(
url=source.get("url"),
height=source.get("height"),
width=source.get("width")
))
return images
def _parse_artist(self, artist_data: Dict) -> Artist:
"""
Parse an artist object from API data.
:param artist_data: Dictionary representing an artist.
:return: An Artist instance.
"""
return Artist(
id=artist_data["uri"].split(":")[-1],
name=artist_data["profile"]["name"],
uri=artist_data["uri"],
external_urls=self._parse_external_urls(artist_data["uri"], "artist")
)
def _parse_album(self, album_data: Dict) -> Album:
"""
Parse an album object from API data.
:param album_data: Dictionary representing an album.
:return: An Album instance.
"""
artists = []
if album_data.get("artists"):
artists = [self._parse_artist(artist) for artist in album_data.get("artists").get('items', [])]
return Album(
id=album_data["uri"].split(":")[-1],
name=album_data["name"],
uri=album_data["uri"],
external_urls=self._parse_external_urls(album_data["uri"], "album"),
artists=artists,
images=self._parse_images(album_data["coverArt"]["sources"])
)
def _parse_track(self, track_data: Dict) -> Track:
"""
Parse a track object from API data.
:param track_data: Dictionary representing a track.
:return: A Track instance.
"""
duration_ms = 0
aritsts = []
if track_data.get("duration"):
duration_ms = int(track_data.get("duration", 0).get("totalMilliseconds", 0))
elif track_data.get("trackDuration"):
duration_ms = track_data["trackDuration"]["totalMilliseconds"]
if track_data.get("firstArtist"):
for artist in track_data.get("firstArtist").get('items', []):
aritsts.append(self._parse_artist(artist))
elif track_data.get("artists"):
for artist in track_data.get("artists").get('items', []):
aritsts.append(self._parse_artist(artist))
if track_data.get("albumOfTrack"):
album = self._parse_album(track_data["albumOfTrack"])
return Track(
id=track_data["uri"].split(":")[-1],
name=track_data["name"],
uri=track_data["uri"],
external_urls=self._parse_external_urls(track_data["uri"], "track"),
duration_ms=duration_ms,
explicit=track_data.get("explicit", False),
album=self._parse_album(track_data["albumOfTrack"]),
artists=aritsts
)
def _parse_owner(self, owner_data: Dict) -> Optional[Owner]:
"""
Parse an owner object from API data.
:param owner_data: Dictionary representing an owner.
:return: An Owner instance or None if the owner data is empty.
"""
if not owner_data:
return None
return Owner(
id=owner_data.get("uri", "").split(":")[-1],
name=owner_data.get("name", ""),
uri=owner_data.get("uri", ""),
external_urls=self._parse_external_urls(owner_data.get("uri", ""), "user")
)
def _parse_card_artwork(self, sources: List[Dict]) -> List[Image]:
"""
Parse artwork for a browse card.
:param sources: List of artwork source dictionaries.
:return: A list of CardArtwork instances.
"""
return [Image(url=source["url"], height=source.get("height"), width=source.get("width")) for source in sources]
def _parse_browse_card(self, card_data: Dict) -> BrowseCard:
"""
Parse a single browse card.
:param card_data: Dictionary containing card data.
:return: A BrowseCard instance.
"""
card_content = card_data["content"]["data"]["data"]["cardRepresentation"]
artwork_sources = card_content["artwork"]["sources"]
return BrowseCard(
title=card_content["title"]["transformedLabel"],
uri=card_data["uri"],
background_color=card_content["backgroundColor"]["hex"],
artwork=self._parse_card_artwork(artwork_sources)
)
def _parse_playlist(self, playlist_data: Dict) -> Playlist:
"""
Parse a playlist object from API response data.
:param playlist_data: Dictionary containing playlist data.
:return: A Playlist object.
"""
images = self._parse_images(playlist_data.get("images", {}).get("items", []))
owner_data = playlist_data.get("ownerV2", {}).get("data", {})
owner = self._parse_owner(owner_data)
valid_tracks = []
for item in playlist_data.get("content", {}).get("items", []):
data = item.get("itemV2", {}).get("data", {})
uri = data.get("uri", "")
if uri.startswith("spotify:track"):
valid_tracks.append(self._parse_track(data))
tracks = valid_tracks
return Playlist(
id=playlist_data.get("uri", "").split(":")[-1],
name=playlist_data.get("name", ""),
uri=playlist_data.get("uri", ""),
external_urls=self._parse_external_urls(playlist_data.get("uri", "").split(":")[-1], "playlist"),
description=playlist_data.get("description", ""),
public=playlist_data.get("public", None),
collaborative=playlist_data.get("collaborative", None),
followers=playlist_data.get("followers", 0),
images=images,
owner=owner,
tracks=[
PlaylistTrack(
added_at=item.get("addedAt", {}).get("isoString", ""),
added_by=None,
is_local=False,
track=track
)
for item, track in zip(
playlist_data.get("content", {}).get("items", []),
tracks
)
]
)
def _parse_browse_section(self, section_data: Dict) -> BrowseSection:
"""
Parse a single browse section.
:param section_data: Dictionary containing section data.
:return: A BrowseSection instance.
"""
section_title = section_data["data"]["title"]["transformedLabel"]
section_items = [
item for item in section_data["sectionItems"]["items"]
if not item["uri"].startswith("spotify:xlink")
]
return BrowseSection(
title=section_title,
items=[self._parse_browse_card(item) for item in section_items],
uri=section_data["uri"]
)
#endregion
def get_playlist(self, playlist_id: str) -> Playlist:
"""
Fetch a playlist by ID with all tracks.
"""
limit = 50
offset = 0
all_items = []
while True:
query_parameters = {
"operationName": "fetchPlaylist",
"variables": json.dumps({
"uri": f"spotify:playlist:{playlist_id}",
"offset": offset,
"limit": limit
}),
"extensions": json.dumps({
"persistedQuery": {
"version": 1,
"sha256Hash": "19ff1327c29e99c208c86d7a9d8f1929cfdf3d3202a0ff4253c821f1901aa94d"
}
})
}
encoded_query = urlencode(query_parameters)
data = self._make_request(f"pathfinder/v1/query?{encoded_query}")
playlist_data = data.get('data', {}).get('playlistV2', {})
content = playlist_data.get('content', {})
items = content.get('items', [])
all_items.extend(items)
if len(all_items) >= content.get('totalCount', 0):
break
offset += limit
playlist_data["content"]["items"] = all_items
return self._parse_playlist(playlist_data)
def extract_playlist_id(self, uri: str) -> str:
"""
Extract the playlist ID from a Spotify URI.
"""
# check whether the uri is a full url with https or just a uri
if uri.startswith("https://open.spotify.com/"):
#if it starts with https, we need to extract the playlist id from the url
return uri.split('/')[-1]
elif uri.startswith("spotify:playlist:"):
return uri.split(':')[-1]
else :
raise ValueError("Invalid Spotify URI.")
def search_playlist(self, query: str, limit: int = 50) -> List[Playlist]:
"""
Searches for playlists on Spotify.
:param query: Search query.
:param limit: Maximum number of results.
:return: A list of Playlist objects.
"""
query_parameters = {
"operationName": "searchDesktop",
"variables": json.dumps({
"searchTerm": query,
"offset": 0,
"limit": limit,
"numberOfTopResults": 5,
"includeAudiobooks": False,
"includeArtistHasConcertsField": False,
"includePreReleases": False,
"includeLocalConcertsField": False
}),
"extensions": json.dumps({
"persistedQuery": {
"version": 1,
"sha256Hash": "f1f1c151cd392433ef4d2683a10deb9adeefd660f29692d8539ce450d2dfdb96"
}
})
}
encoded_query = urlencode(query_parameters)
url = f"pathfinder/v1/query?{encoded_query}"
try:
response = self._make_request(url)
search_data = response.get("data", {}).get("searchV2", {})
playlists_data = search_data.get("playlists", {}).get("items", [])
playlists = [self._parse_playlist(item["data"]) for item in playlists_data]
return playlists
except Exception as e:
print(f"An error occurred while searching for playlists: {e}")
return []
def get_track(self, track_id: str) -> Track:
"""
Fetches details for a specific track.
:param track_id: The ID of the track.
:return: A Track object.
"""
query_parameters = {
"operationName": "getTrack",
"variables": json.dumps({
"uri": f"spotify:track:{track_id}"
}),
"extensions": json.dumps({
"persistedQuery": {
"version": 1,
"sha256Hash": "5c5ec8c973a0ac2d5b38d7064056c45103c5a062ee12b62ce683ab397b5fbe7d"
}
})
}
encoded_query = urlencode(query_parameters)
url = f"pathfinder/v1/query?{encoded_query}"
try:
response = self._make_request(url)
track_data = response.get("data", {}).get("trackUnion", {})
return self._parse_track(track_data)
except Exception as e:
print(f"An error occurred while fetching the track: {e}")
return None
# non generic method implementations:
def get_profile(self) -> Optional[Profile]:
"""
Fetch the profile attributes of the authenticated Spotify user.
:return: A Profile object containing the user's profile information or None if an error occurs.
"""
query_parameters = {
"operationName": "profileAttributes",
"variables": json.dumps({}),
"extensions": json.dumps({
"persistedQuery": {
"version": 1,
"sha256Hash": "53bcb064f6cd18c23f752bc324a791194d20df612d8e1239c735144ab0399ced"
}
})
}
encoded_query = urlencode(query_parameters)
url = f"pathfinder/v1/query?{encoded_query}"
try:
response = self._make_request(url)
profile_data = response.get('data', {}).get('me', {}).get('profile', {})
if not profile_data:
raise ValueError("Invalid profile data received.")
return Profile(
avatar=profile_data.get("avatar"),
avatar_background_color=profile_data.get("avatarBackgroundColor"),
name=profile_data.get("name", ""),
uri=profile_data.get("uri", ""),
username=profile_data.get("username", "")
)
except Exception as e:
print(f"An error occurred while fetching profile attributes: {e}")
return None
def get_account_attributes(self) -> Optional[AccountAttributes]:
"""
Fetch the account attributes of the authenticated Spotify user.
:return: An AccountAttributes object containing the user's account information or None if an error occurs.
"""
# Define the query parameters
query_parameters = {
"operationName": "accountAttributes",
"variables": json.dumps({}), # Empty variables for this query
"extensions": json.dumps({
"persistedQuery": {
"version": 1,
"sha256Hash": "4fbd57be3c6ec2157adcc5b8573ec571f61412de23bbb798d8f6a156b7d34cdf"
}
})
}
# Encode the query parameters
encoded_query = urlencode(query_parameters)
# API endpoint
url = f"pathfinder/v1/query?{encoded_query}"
try:
# Perform the request
response = self._make_request(url)
# Extract and validate the account data
account_data = response.get('data', {}).get('me', {}).get('account', {})
attributes = account_data.get("attributes", {})
if not attributes or not account_data.get("country") or not account_data.get("product"):
raise ValueError("Invalid account data received.")
# Map the response to the AccountAttributes class
return AccountAttributes(
catalogue=attributes.get("catalogue", ""),
dsa_mode_available=attributes.get("dsaModeAvailable", False),
dsa_mode_enabled=attributes.get("dsaModeEnabled", False),
multi_user_plan_current_size=attributes.get("multiUserPlanCurrentSize"),
multi_user_plan_member_type=attributes.get("multiUserPlanMemberType"),
on_demand=attributes.get("onDemand", False),
opt_in_trial_premium_only_market=attributes.get("optInTrialPremiumOnlyMarket", False),
country=account_data.get("country", ""),
product=account_data.get("product", "")
)
except Exception as e:
print(f"An error occurred while fetching account attributes: {e}")
return None
def browse(self, **kwargs) -> List[BrowseSection]:
"""
Fetch all browse sections with cards.
:param kwargs: Keyword arguments. Supported:
- page_limit: Maximum number of pages to fetch (default: 50)
- section_limit: Maximum number of sections per page (default: 99)
:return: A list of BrowseSection objects.
"""
page_limit = kwargs.get('page_limit', 50)
section_limit = kwargs.get('section_limit', 99)
query_parameters = {
"operationName": "browseAll",
"variables": json.dumps({
"pagePagination": {"offset": 0, "limit": page_limit},
"sectionPagination": {"offset": 0, "limit": section_limit}
}),
"extensions": json.dumps({
"persistedQuery": {
"version": 1,
"sha256Hash": "cd6fcd0ce9d1849477645646601a6d444597013355467e24066dad2c1dc9b740"
}
})
}
encoded_query = urlencode(query_parameters)
url = f"pathfinder/v1/query?{encoded_query}"
try:
response = self._make_request(url)
browse_data = response.get("data", {}).get("browseStart", {}).get("sections", {})
sections = browse_data.get("items", [])
return [self._parse_browse_section(section) for section in sections]
except Exception as e:
print(f"An error occurred while fetching browse sections: {e}")
return []
def browse_page(self, uri: str) -> List[Playlist]:
"""
Fetch the content of a browse page using the URI.
:param uri: Should start with 'spotify:page'.
:return: A list of Playlist objects from the browse page.
"""
if not uri or not uri.startswith("spotify:page"):
raise ValueError("The 'uri' parameter must be provided and start with 'spotify:page'.")
query_parameters = {
"operationName": "browsePage",
"variables": json.dumps({
"pagePagination": {"offset": 0, "limit": 10},
"sectionPagination": {"offset": 0, "limit": 10},
"uri": uri
}),
"extensions": json.dumps({
"persistedQuery": {
"version": 1,
"sha256Hash": "d8346883162a16a62a5b69e73e70c66a68c27b14265091cd9e1517f48334bbb3"
}
})
}
encoded_query = urlencode(query_parameters)
url = f"pathfinder/v1/query?{encoded_query}"
try:
response = self._make_request(url)
browse_data = response.get("data", {}).get("browse", {})
sections = browse_data.get("sections", {}).get("items", [])
playlists = []
for section in sections:
section_items = section.get("sectionItems", {}).get("items", [])
for item in section_items:
content = item.get("content", {}).get("data", {})
if content.get("__typename") == "Playlist":
playlists.append(self._parse_playlist(content))
return playlists
except Exception as e:
print(f"An error occurred while fetching the browse page: {e}")
return []

3
app/registry/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .music_provider_registry import MusicProviderRegistry
__all__ = ["MusicProviderRegistry"]

View File

@@ -0,0 +1,35 @@
from app.providers.base import MusicProviderClient
class MusicProviderRegistry:
"""
Registry to manage and retrieve music provider clients.
"""
_providers = {}
@classmethod
def register_provider(cls, provider: MusicProviderClient):
"""
Registers a music provider client instance.
:param provider: An instance of a MusicProviderClient subclass.
"""
cls._providers[provider._identifier] = provider
@classmethod
def get_provider(cls, identifier: str) -> MusicProviderClient:
"""
Retrieves a registered music provider client by its identifier.
:param identifier: The unique identifier for the provider.
:return: An instance of MusicProviderClient.
"""
if identifier not in cls._providers:
raise ValueError(f"No provider found with identifier '{identifier}'.")
return cls._providers[identifier]
@classmethod
def list_providers(cls) -> list:
"""
Lists all registered providers.
:return: A list of registered provider identifiers.
"""
return list(cls._providers.keys())

View File

@@ -1,267 +0,0 @@
from flask import Flask, Response, jsonify, render_template, request, redirect, url_for, session, flash
from app import app, db, functions, sp, jellyfin, celery, jellyfin_admin_token, jellyfin_admin_id,device_id, cache
from app.models import JellyfinUser,Playlist,Track
from celery.result import AsyncResult
from .version import __version__
@app.context_processor
def add_context():
unlinked_track_count = len(Track.query.filter_by(downloaded=True,jellyfin_id=None).all())
version = f"v{__version__}"
return dict(unlinked_track_count = unlinked_track_count, version = version)
@app.after_request
def render_messages(response: Response) -> Response:
if request.headers.get("HX-Request"):
messages = render_template("partials/alerts.jinja2")
response.headers['HX-Trigger'] = 'showToastMessages'
response.data = response.data + messages.encode("utf-8")
return response
@app.route('/admin/tasks')
@functions.jellyfin_admin_required
def task_manager():
statuses = {}
for task_name, task_id in functions.TASK_STATUS.items():
if task_id:
result = AsyncResult(task_id)
statuses[task_name] = {'state': result.state, 'info': result.info if result.info else {}}
else:
statuses[task_name] = {'state': 'NOT STARTED', 'info': {}}
return render_template('admin/tasks.html', tasks=statuses)
@app.route('/admin')
@app.route('/admin/link_issues')
@functions.jellyfin_admin_required
def link_issues():
unlinked_tracks = Track.query.filter_by(downloaded=True,jellyfin_id=None).all()
tracks = []
for ult in unlinked_tracks:
sp_track = functions.get_cached_spotify_track(ult.spotify_track_id)
duration_ms = sp_track['duration_ms']
minutes = duration_ms // 60000
seconds = (duration_ms % 60000) // 1000
tracks.append({
'title': sp_track['name'],
'artist': ', '.join([artist['name'] for artist in sp_track['artists']]),
'url': sp_track['external_urls']['spotify'],
'duration': f'{minutes}:{seconds:02d}',
'preview_url': sp_track['preview_url'],
'downloaded': ult.downloaded,
'filesystem_path': ult.filesystem_path,
'jellyfin_id': ult.jellyfin_id,
'spotify_id': sp_track['id'],
'duration_ms': duration_ms,
'download_status' : ult.download_status
})
return render_template('admin/link_issues.html' , tracks = tracks )
@app.route('/run_task/<task_name>', methods=['POST'])
@functions.jellyfin_admin_required
def run_task(task_name):
status, info = functions.manage_task(task_name)
# Rendere nur die aktualisierte Zeile der Task
task_info = {task_name: {'state': status, 'info': info}}
return render_template('partials/_task_status.html', tasks=task_info)
@app.route('/task_status')
@functions.jellyfin_admin_required
def task_status():
statuses = {}
for task_name, task_id in functions.TASK_STATUS.items():
if task_id:
result = AsyncResult(task_id)
statuses[task_name] = {'state': result.state, 'info': result.info if result.info else {}}
else:
statuses[task_name] = {'state': 'NOT STARTED', 'info': {}}
# Render the HTML partial template instead of returning JSON
return render_template('partials/_task_status.html', tasks=statuses)
@app.route('/')
@functions.jellyfin_login_required
def index():
users = JellyfinUser.query.all()
return render_template('index.html', user=session['jellyfin_user_name'], users=users)
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
try:
jellylogin = jellyfin.login_with_password(username=username, password=password)
if jellylogin:
session['jellyfin_access_token'], session['jellyfin_user_id'], session['jellyfin_user_name'],session['is_admin'] = jellylogin
session['debug'] = app.debug
# Check if the user already exists
user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
if not user:
# Add the user to the database if they don't exist
new_user = JellyfinUser(name=session['jellyfin_user_name'], jellyfin_user_id=session['jellyfin_user_id'], is_admin = session['is_admin'])
db.session.add(new_user)
db.session.commit()
return redirect('/playlists')
except:
flash('Login failed. Please check your Jellyfin credentials and try again.', 'error')
return redirect(url_for('login'))
return render_template('login.html')
@app.route('/logout')
def logout():
session.pop('jellyfin_user_name', None)
session.pop('jellyfin_access_token', None)
return redirect(url_for('login'))
@app.route('/playlists')
@app.route('/categories')
@app.route('/playlists/monitored')
@functions.jellyfin_login_required
def loaditems():
country = 'DE'
offset = int(request.args.get('offset', 0)) # Get the offset (default to 0 for initial load)
limit = 20 # Define a limit for pagination
additional_query = ''
items_subtitle = ''
if request.path == '/playlists/monitored':
# Step 1: Query the database for monitored playlists
db_playlists = db.session.query(Playlist).offset(offset).limit(limit).all()
max_items = db.session.query(Playlist).count()
# Collect Spotify Playlist IDs from the database
spotify_playlist_ids = [playlist.spotify_playlist_id for playlist in db_playlists]
spotify_data = functions.get_cached_spotify_playlists(tuple(spotify_playlist_ids))
# Step 3: Pass the Spotify data to prepPlaylistData for processing
data = functions.prepPlaylistData(spotify_data)
items_title = "Monitored Playlists"
items_subtitle = "This playlists are already monitored by the Server, if you add one of these to your Jellyfin account, they will be available immediately."
elif request.path == '/playlists':
cat = request.args.get('cat', None)
if cat is not None:
data, max_items, items_title = functions.getCategoryPlaylists(category=cat, offset=offset)
additional_query += f"&cat={cat}"
else:
data, max_items, items_title = functions.getFeaturedPlaylists(country=country, offset=offset)
elif request.path == '/categories':
data, max_items, items_title = functions.getCategories(country=country, offset=offset)
next_offset = offset + len(data)
total_items = max_items
context = {
'items': data,
'next_offset': next_offset,
'total_items': total_items,
'endpoint': request.path,
'items_title': items_title,
'items_subtitle' : items_subtitle,
'additional_query': additional_query
}
if request.headers.get('HX-Request'): # Check if the request is from HTMX
return render_template('partials/_spotify_items.html', **context)
else:
return render_template('items.html', **context)
@app.route('/search')
@functions.jellyfin_login_required
def searchResults():
query = request.args.get('query')
context = {}
if query:
# Add your logic here to perform the search on Spotify (or Jellyfin)
search_result = sp.search(q = query, type= 'track,album,artist,playlist')
context = {
'artists' : functions.prepArtistData(search_result ),
'playlists' : functions.prepPlaylistData(search_result ),
'albums' : functions.prepAlbumData(search_result ),
'query' : query
}
return render_template('search.html', **context)
else:
return render_template('search.html', query=None, results={})
@app.route('/playlist/view/<playlist_id>')
@functions.jellyfin_login_required
def get_playlist_tracks(playlist_id):
# Hol dir alle Tracks für die Playlist
data = functions.get_full_playlist_data(playlist_id) # Diese neue Funktion holt alle Tracks der Playlist
tracks = functions.get_tracks_for_playlist(data) # Deine Funktion, um Tracks zu holen
# Berechne die gesamte Dauer der Playlist
total_duration_ms = sum([track['track']['duration_ms'] for track in data['tracks'] if track['track']])
# Konvertiere die Gesamtdauer in ein lesbares Format
hours, remainder = divmod(total_duration_ms // 1000, 3600)
minutes, seconds = divmod(remainder, 60)
# Formatierung der Dauer
if hours > 0:
total_duration = f"{hours}h {minutes}min"
else:
total_duration = f"{minutes}min"
return render_template(
'tracks_table.html',
tracks=tracks,
total_duration=total_duration,
track_count=len(data['tracks']),
playlist_name=data['name'],
playlist_cover=data['images'][0]['url'],
playlist_description=data['description'],
last_updated = data['prepped_data'][0]['last_updated'],
last_changed = data['prepped_data'][0]['last_changed'],
item = data['prepped_data'][0],
)
@app.route('/associate_track', methods=['POST'])
@functions.jellyfin_login_required
def associate_track():
jellyfin_id = request.form.get('jellyfin_id')
spotify_id = request.form.get('spotify_id')
if not jellyfin_id or not spotify_id:
flash('Missing Jellyfin or Spotify ID')
# Retrieve the track by Spotify ID
track = Track.query.filter_by(spotify_track_id=spotify_id).first()
if not track:
flash('Track not found')
return ''
# Associate the Jellyfin ID with the track
track.jellyfin_id = jellyfin_id
try:
# Commit the changes to the database
db.session.commit()
flash("Track associated","success")
return ''
except Exception as e:
db.session.rollback() # Roll back the session in case of an error
flash(str(e))
return ''
@app.route('/test')
def test():
return ''

17
app/routes/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
from flask import Blueprint, request, g
from app import app
from app.registry.music_provider_registry import MusicProviderRegistry
pl_bp = Blueprint('playlist', __name__)
@pl_bp.before_request
def set_active_provider():
"""
Middleware to select the active provider based on request parameters.
"""
app.logger.debug(f"Setting active provider: {request.args.get('provider', 'Spotify')}")
provider_id = request.args.get('provider', 'Spotify') # Default to Spotify
try:
g.music_provider = MusicProviderRegistry.get_provider(provider_id)
except ValueError as e:
return {"error": str(e)}, 400

View File

@@ -0,0 +1,250 @@
from collections import defaultdict
import time
from flask import Blueprint, Flask, jsonify, render_template, request, redirect, url_for, session, flash
from sqlalchemy import insert
from app import app, db, jellyfin, functions, device_id,sp
from app.models import JellyfinUser, Playlist,Track, playlist_tracks
from spotipy.exceptions import SpotifyException
from app.tasks import task_manager
from app.registry.music_provider_registry import MusicProviderRegistry
from jellyfin.objects import PlaylistMetadata
from app.routes import pl_bp, routes
@app.route('/jellyfin_playlists')
@functions.jellyfin_login_required
def jellyfin_playlists():
playlists = jellyfin.get_playlists(session_token=functions._get_token_from_sessioncookie())
playlists_by_provider = defaultdict(list)
provider_playlists_data = {}
for pl in playlists:
from_db : Playlist | None = Playlist.query.filter_by(jellyfin_id=pl['Id']).first()
if from_db and from_db.provider_playlist_id:
pl_id = from_db.provider_playlist_id
playlists_by_provider[from_db.provider_id].append(from_db)
# 3. Fetch all Data from the provider using the get_playlist() method
for provider_id, playlists in playlists_by_provider.items():
try:
provider_client = MusicProviderRegistry.get_provider(provider_id)
except ValueError:
flash(f"Provider {provider_id} not found.", "error")
continue
combined_playlists = []
for pl in playlists:
# Use the cached provider_playlist_id to fetch the playlist from the provider
provider_playlist = functions.get_cached_provider_playlist(pl.provider_playlist_id,pl.provider_id)
#provider_playlist = provider_client.get_playlist(pl.provider_playlist_id)
# 4. Convert the playlists to CombinedPlaylistData
combined_data = functions.prepPlaylistData(provider_playlist)
if combined_data:
combined_playlists.append(combined_data)
provider_playlists_data[provider_id] = combined_playlists
# 5. Display the resulting Groups in a template called 'monitored_playlists.html', one Heading per Provider
return render_template('monitored_playlists.html', provider_playlists_data=provider_playlists_data,title="Jellyfin Playlists" , subtitle="Playlists you have added to Jellyfin")
@pl_bp.route('/addplaylist', methods=['POST'])
@functions.jellyfin_login_required
def add_playlist():
playlist_id = request.form.get('item_id')
playlist_name = request.form.get('item_name')
additional_users = None
if not playlist_id and request.data:
# get data convert from json to dict
data = request.get_json()
playlist_id = data.get('item_id')
playlist_name = data.get('item_name')
additional_users = data.get('additional_users')
# also get the provider id from the query params
provider_id = request.args.get('provider')
if not playlist_id:
flash('No playlist ID provided')
return ''
# if no provider_id is provided, then show an error and return an empty string
if not provider_id:
flash('No provider ID provided')
return ''
try:
# get the playlist from the correct provider
provider_client = MusicProviderRegistry.get_provider(provider_id)
playlist_data = provider_client.get_playlist(playlist_id)
# Check if playlist already exists in the database, using the provider_id and the provider_playlist_id
playlist = Playlist.query.filter_by(provider_playlist_id=playlist_id, provider_id=provider_id).first()
# Add new playlist in the database if it doesn't exist
# create the playlist via api key, with the first admin as 'owner'
if not playlist:
fromJellyfin = jellyfin.create_music_playlist(functions._get_api_token(),playlist_data.name,[],functions._get_admin_id())['Id']
playlist = Playlist(name=playlist_data.name, provider_playlist_id=playlist_id,provider_uri=playlist_data.uri,track_count = len(playlist_data.tracks), tracks_available=0, jellyfin_id = fromJellyfin, provider_id=provider_id)
db.session.add(playlist)
db.session.commit()
if app.config['START_DOWNLOAD_AFTER_PLAYLIST_ADD']:
task_manager.start_task('download_missing_tracks')
# Get the logged-in user
user : JellyfinUser = functions._get_logged_in_user()
playlist.tracks_available = 0
for idx, track_data in enumerate(playlist_data.tracks):
track = Track.query.filter_by(provider_track_id=track_data.track.id, provider_id=provider_id).first()
if not track:
# Add new track if it doesn't exist
track = Track(name=track_data.track.name, provider_track_id=track_data.track.id, provider_uri=track_data.track.uri, downloaded=False,provider_id = provider_id)
db.session.add(track)
db.session.commit()
elif track.downloaded:
playlist.tracks_available += 1
db.session.commit()
# Add track to playlist with order if it's not already associated
if track not in playlist.tracks:
# Insert into playlist_tracks with track order
stmt = insert(playlist_tracks).values(
playlist_id=playlist.id,
track_id=track.id,
track_order=idx # Maintain the order of tracks
)
db.session.execute(stmt)
db.session.commit()
functions.update_playlist_metadata(playlist,playlist_data)
if playlist not in user.playlists:
user.playlists.append(playlist)
db.session.commit()
jellyfin.add_users_to_playlist(session_token=functions._get_api_token(), user_id=functions._get_admin_id(),playlist_id = playlist.jellyfin_id,user_ids= [user.jellyfin_user_id])
flash(f'Playlist "{playlist_data.name}" successfully added','success')
else:
flash(f'Playlist "{playlist_data.name}" already in your list')
item = {
"name" : playlist_data.name,
"id" : playlist_id,
"can_add":False,
"can_remove":True,
"jellyfin_id" : playlist.jellyfin_id
}
if additional_users and session['is_admin']:
db.session.commit()
app.logger.debug(f"Additional users: {additional_users}")
for user_id in additional_users:
routes.add_jellyfin_user_to_playlist_internal(user_id,playlist.jellyfin_id)
return render_template('partials/_add_remove_button.html',item= item)
except Exception as e:
flash(str(e))
return ''
@app.route('/delete_playlist/<playlist_id>', methods=['DELETE'])
@functions.jellyfin_login_required
def delete_playlist(playlist_id):
# Logic to delete the playlist using JellyfinClient
try:
user = functions._get_logged_in_user()
for pl in user.playlists:
if pl.jellyfin_id == playlist_id:
user.playlists.remove(pl)
playlist = pl
jellyfin.remove_user_from_playlist(session_token= functions._get_api_token(), playlist_id= playlist_id, user_id=user.jellyfin_user_id)
db.session.commit()
flash('Playlist removed')
item = {
"name" : playlist.name,
"id" : playlist.provider_playlist_id,
"can_add":True,
"can_remove":False,
"jellyfin_id" : playlist.jellyfin_id
}
return render_template('partials/_add_remove_button.html',item= item)
except Exception as e:
flash(f'Failed to remove item: {str(e)}')
@app.route('/refresh_playlist/<playlist_id>', methods=['GET'])
@functions.jellyfin_admin_required
def refresh_playlist(playlist_id):
# get the playlist from the database using the playlist_id
playlist = Playlist.query.filter_by(jellyfin_id=playlist_id).first()
# if the playlist has a jellyfin_id, then fetch the playlist from Jellyfin
if playlist.jellyfin_id:
try:
app.logger.debug(f"removing all tracks from playlist {playlist.jellyfin_id}")
jellyfin_playlist = jellyfin.get_music_playlist(session_token=functions._get_api_token(), playlist_id=playlist.jellyfin_id)
jellyfin.remove_songs_from_playlist(session_token=functions._get_token_from_sessioncookie(), playlist_id=playlist.jellyfin_id, song_ids=[track for track in jellyfin_playlist['ItemIds']])
ordered_tracks = db.session.execute(
db.select(Track, playlist_tracks.c.track_order)
.join(playlist_tracks, playlist_tracks.c.track_id == Track.id)
.where(playlist_tracks.c.playlist_id == playlist.id)
.order_by(playlist_tracks.c.track_order)
).all()
tracks = [track.jellyfin_id for track, idx in ordered_tracks if track.jellyfin_id is not None]
#jellyfin.remove_songs_from_playlist(session_token=jellyfin_admin_token, playlist_id=playlist.jellyfin_id, song_ids=tracks)
jellyfin.add_songs_to_playlist(session_token=functions._get_api_token(), user_id=functions._get_admin_id(), playlist_id=playlist.jellyfin_id, song_ids=tracks)
# if the playlist is found, then update the playlist metadata
provider_playlist = MusicProviderRegistry.get_provider(playlist.provider_id).get_playlist(playlist.provider_playlist_id)
functions.update_playlist_metadata(playlist, provider_playlist)
flash('Playlist refreshed')
return jsonify({'success': True})
except Exception as e:
flash(f'Failed to refresh playlist: {str(e)}')
return jsonify({'success': False})
@app.route('/wipe_playlist/<playlist_id>', methods=['DELETE'])
@functions.jellyfin_admin_required
def wipe_playlist(playlist_id):
playlist = Playlist.query.filter_by(jellyfin_id=playlist_id).first()
name = ""
id = ""
jf_id = ""
try:
jellyfin.remove_item(session_token=functions._get_api_token(), playlist_id=playlist_id)
except Exception as e:
flash(f"Jellyfin API Error: {str(e)}")
if playlist:
# Delete the playlist
name = playlist.name
id = playlist.provider_playlist_id
jf_id = playlist.jellyfin_id
db.session.delete(playlist)
db.session.commit()
flash('Playlist Deleted', category='info')
item = {
"name" : name,
"id" : id,
"can_add":True,
"can_remove":False,
"jellyfin_id" : jf_id
}
return render_template('partials/_add_remove_button.html',item= item)
@functions.jellyfin_login_required
@app.route('/get_jellyfin_stream/<string:jellyfin_id>')
def get_jellyfin_stream(jellyfin_id):
user_id = session['jellyfin_user_id'] # Beispiel: dynamischer Benutzer
api_key = functions._get_token_from_sessioncookie() # Beispiel: dynamischer API-Schlüssel
stream_url = f"{app.config['JELLYFIN_SERVER_URL']}/Audio/{jellyfin_id}/universal?UserId={user_id}&DeviceId={device_id}&MaxStreamingBitrate=140000000&Container=opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg&TranscodingContainer=mp4&TranscodingProtocol=hls&AudioCodec=aac&api_key={api_key}&PlaySessionId={int(time.time())}&StartTimeTicks=0&EnableRedirection=true&EnableRemoteMedia=false"
return jsonify({'stream_url': stream_url})
@app.route('/search_jellyfin', methods=['GET'])
@functions.jellyfin_login_required
def search_jellyfin():
search_query = request.args.get('search_query')
provider_track_id = request.args.get('provider_track_id')
if search_query:
results = jellyfin.search_music_tracks(functions._get_token_from_sessioncookie(), search_query)
# Render only the search results section as response
return render_template('partials/_jf_search_results.html', results=results,provider_track_id= provider_track_id,search_query = search_query)
return jsonify({'error': 'No search query provided'}), 400

556
app/routes/routes.py Normal file
View File

@@ -0,0 +1,556 @@
from dbm import error
import json
import os
import re
from flask import Flask, Response, jsonify, render_template, request, redirect, url_for, session, flash, Blueprint, g
from app import app, db, functions, jellyfin, read_dev_build_file, tasks, save_yaml_settings
from app.classes import AudioProfile, CombinedPlaylistData
from app.models import JellyfinUser,Playlist,Track
from celery.result import AsyncResult
from typing import List
from app.providers import base
from app.providers.base import MusicProviderClient
from app.providers.spotify import SpotifyClient
from app.registry.music_provider_registry import MusicProviderRegistry
from lidarr.classes import Album, Artist
from lidarr.client import LidarrClient
from ..version import __version__
from spotipy.exceptions import SpotifyException
from collections import defaultdict
from app.routes import pl_bp
@app.context_processor
def add_context():
unlinked_track_count = len(Track.query.filter_by(downloaded=True,jellyfin_id=None).all())
version = f"v{__version__}{read_dev_build_file()}"
return dict(unlinked_track_count = unlinked_track_count, version = version, config = app.config , registered_providers = MusicProviderRegistry.list_providers())
# this feels wrong
skip_endpoints = ['task_status']
@app.after_request
def render_messages(response: Response) -> Response:
if request.headers.get("HX-Request"):
if request.endpoint not in skip_endpoints:
messages = render_template("partials/alerts.jinja2")
response.headers['HX-Trigger'] = 'showToastMessages'
response.data = response.data + messages.encode("utf-8")
return response
@app.route('/admin/lidarr')
@functions.jellyfin_admin_required
def admin_lidarr():
if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']:
from app import lidarr_client
q_profiles = lidarr_client.get_quality_profiles()
root_folders = lidarr_client.get_root_folders()
return render_template('admin/lidarr.html',quality_profiles = q_profiles, root_folders = root_folders, current_quality_profile = functions.lidarr_quality_profile_id(), current_root_folder = functions.lidarr_root_folder_path())
return render_template('admin/lidarr.html', error = 'Lidarr not configured')
@app.route('/admin/lidarr/save', methods=['POST'])
@functions.jellyfin_admin_required
def save_lidarr_config():
quality_profile_id = request.form.get('qualityProfile')
root_folder_id = request.form.get('rootFolder')
if not quality_profile_id or not root_folder_id:
flash('Both Quality Profile and Root Folder must be selected', 'danger')
return redirect(url_for('admin_lidarr'))
functions.lidarr_quality_profile_id(quality_profile_id)
functions.lidarr_root_folder_path(root_folder_id)
flash('Configuration saved successfully', 'success')
return redirect(url_for('admin_lidarr'))
@app.route('/admin/tasks')
@functions.jellyfin_admin_required
def task_manager():
statuses = {}
lock_keys = []
for task_name, task_id in tasks.task_manager.tasks.items():
statuses[task_name] = tasks.task_manager.get_task_status(task_name)
lock_keys.append(f"{task_name}_lock")
lock_keys.append('full_update_jellyfin_ids_lock')
return render_template('admin/tasks.html', tasks=statuses,lock_keys = lock_keys)
@app.route('/admin/link_issues')
@functions.jellyfin_admin_required
def link_issues():
# add the ability to pass a query parameter to dislplay even undownloaded tracks
list_undownloaded = request.args.get('list_undownloaded')
if list_undownloaded:
unlinked_tracks = Track.query.filter_by(jellyfin_id=None).all()
else:
unlinked_tracks = Track.query.filter_by(downloaded=True,jellyfin_id=None).all()
tracks = []
for ult in unlinked_tracks:
provider_track = functions.get_cached_provider_track(ult.provider_track_id, ult.provider_id)
duration_ms = provider_track.duration_ms
minutes = duration_ms // 60000
seconds = (duration_ms % 60000) // 1000
tracks.append({
'title': provider_track.name,
'artist': ', '.join([artist.name for artist in provider_track.artists]),
'url': provider_track.external_urls,
'duration': f'{minutes}:{seconds:02d}',
'preview_url': '',
'downloaded': ult.downloaded,
'filesystem_path': ult.filesystem_path,
'jellyfin_id': ult.jellyfin_id,
'provider_track_id': provider_track.id,
'duration_ms': duration_ms,
'download_status' : ult.download_status,
'provider_id' : ult.provider_id
})
return render_template('admin/link_issues.html' , tracks = tracks )
@app.route('/admin/logs')
@functions.jellyfin_admin_required
def view_logs():
# parse the query parameter
log_name = request.args.get('name')
logs = []
if log_name == 'logs' or not log_name and os.path.exists('/var/log/jellyplist.log'):
with open('/var/log/jellyplist.log', 'r',encoding='utf-8') as f:
logs = f.readlines()
if log_name == 'worker' and os.path.exists('/var/log/jellyplist_worker.log'):
with open('/var/log/jellyplist_worker.log', 'r', encoding='utf-8') as f:
logs = f.readlines()
if log_name == 'beat' and os.path.exists('/var/log/jellyplist_beat.log'):
with open('/var/log/jellyplist_beat.log', 'r',encoding='utf-8') as f:
logs = f.readlines()
return render_template('admin/logview.html', logs=str.join('',logs),name=log_name)
@app.route('/admin/setloglevel', methods=['POST'])
@functions.jellyfin_admin_required
def set_log_level():
loglevel = request.form.get('logLevel')
if loglevel:
if loglevel in ['DEBUG','INFO','WARNING','ERROR','CRITICAL']:
functions.set_log_level(loglevel)
flash(f'Log level set to {loglevel}', category='success')
return redirect(url_for('view_logs'))
@app.route('/admin/logs/getLogsForIssue')
@functions.jellyfin_admin_required
def get_logs_for_issue():
# get the last 200 lines of all log files
last_lines = -300
logs = []
logs += f'## Logs and Details for Issue ##\n'
logs += f'Version: *{__version__}{read_dev_build_file()}*\n'
if os.path.exists('/var/log/jellyplist.log'):
with open('/var/log/jellyplist.log', 'r',encoding='utf-8') as f:
logs += f'### jellyfin.log\n'
logs += f'```log\n'
logs += f.readlines()[last_lines:]
logs += f'```\n'
if os.path.exists('/var/log/jellyplist_worker.log'):
with open('/var/log/jellyplist_worker.log', 'r', encoding='utf-8') as f:
logs += f'### jellyfin_worker.log\n'
logs += f'```log\n'
logs += f.readlines()[last_lines:]
logs += f'```\n'
if os.path.exists('/var/log/jellyplist_beat.log'):
with open('/var/log/jellyplist_beat.log', 'r',encoding='utf-8') as f:
logs += f'### jellyplist_beat.log\n'
logs += f'```log\n'
logs += f.readlines()[last_lines:]
logs += f'```\n'
# in the logs array, anonymize IP addresses
logs = [re.sub(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', 'xxx.xxx.xxx.xxx', log) for log in logs]
return jsonify({'logs': logs})
@app.route('/admin')
@app.route('/admin/settings')
@app.route('/admin/settings/save' , methods=['POST'])
@functions.jellyfin_admin_required
def admin_settings():
# if the request is a POST request, save the settings
if request.method == 'POST':
# from the form, get all values from default_playlist_users and join them to one array of strings
app.config['runtime_settings']['default_playlist_users'] = request.form.getlist('default_playlist_users')
save_yaml_settings()
flash('Settings saved', category='success')
return redirect('/admin/settings')
return render_template('admin/settings.html',jellyfin_users = jellyfin.get_users(session_token=functions._get_api_token()))
@app.route('/run_task/<task_name>', methods=['POST'])
@functions.jellyfin_admin_required
def run_task(task_name):
status, info = tasks.task_manager.start_task(task_name)
# Rendere nur die aktualisierte Zeile der Task
task_info = {task_name: {'state': status, 'info': info}}
return render_template('partials/_task_status.html', tasks=task_info)
@app.route('/task_status')
@functions.jellyfin_admin_required
def task_status():
statuses = {}
lock_keys = []
for task_name, task_id in tasks.task_manager.tasks.items():
statuses[task_name] = tasks.task_manager.get_task_status(task_name)
lock_keys.append(f"{task_name}_lock")
lock_keys.append('full_update_jellyfin_ids_lock')
# Render the HTML partial template instead of returning JSON
return render_template('partials/_task_status.html', tasks=statuses, lock_keys = lock_keys)
@app.route('/')
@functions.jellyfin_login_required
def index():
users = JellyfinUser.query.all()
return render_template('index.html', user=session['jellyfin_user_name'], users=users)
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
try:
jellylogin = jellyfin.login_with_password(username=username, password=password)
if jellylogin:
session['jellyfin_access_token'], session['jellyfin_user_id'], session['jellyfin_user_name'],session['is_admin'] = jellylogin
session['debug'] = app.debug
# Check if the user already exists
user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
if not user:
# Add the user to the database if they don't exist
new_user = JellyfinUser(name=session['jellyfin_user_name'], jellyfin_user_id=session['jellyfin_user_id'], is_admin = session['is_admin'])
db.session.add(new_user)
db.session.commit()
return redirect('/')
except:
flash('Login failed. Please check your Jellyfin credentials and try again.', 'error')
return redirect(url_for('login'))
return render_template('login.html')
@app.route('/logout')
def logout():
session.pop('jellyfin_user_name', None)
session.pop('jellyfin_access_token', None)
return redirect(url_for('login'))
@app.route('/playlist/open',methods=['GET'])
@functions.jellyfin_login_required
def openPlaylist():
playlist = request.args.get('playlist')
error = None
errdata= None
if playlist:
for provider_id in MusicProviderRegistry.list_providers():
try:
provider_client = MusicProviderRegistry.get_provider(provider_id)
extracted_playlist_id = provider_client.extract_playlist_id(playlist)
provider_playlist = functions.get_cached_provider_playlist(extracted_playlist_id, provider_id)
combined_data = functions.prepPlaylistData(provider_playlist)
if combined_data:
# If the playlist is found, redirect to the playlist view, but also include the provider ID in the URL
return redirect(url_for('playlist.get_playlist_tracks', playlist_id=extracted_playlist_id, provider=provider_id))
except Exception as e:
error = f"Error fetching playlist from {provider_id}: {str(e)}"
errdata = e
return render_template('index.html',error_message = error, error_data = errdata)
@pl_bp.route('/browse')
@functions.jellyfin_login_required
def browse():
provider: MusicProviderClient = g.music_provider
browse_data = provider.browse()
return render_template('browse.html', browse_data=browse_data,provider_id=provider._identifier)
@pl_bp.route('/browse/page/<page_id>')
@functions.jellyfin_login_required
def browse_page(page_id):
provider: MusicProviderClient = g.music_provider
combined_playlist_data : List[CombinedPlaylistData] = []
data = provider.browse_page(page_id)
for item in data:
cpd = functions.prepPlaylistData(item)
if cpd:
combined_playlist_data.append(cpd)
return render_template('browse_page.html', data=combined_playlist_data,provider_id=provider._identifier)
@pl_bp.route('/playlists/monitored')
@functions.jellyfin_login_required
def monitored_playlists():
# 1. Get all Playlists from the Database and order them by Id
all_playlists = Playlist.query.order_by(Playlist.id).all()
# 2. Group them by provider
playlists_by_provider = defaultdict(list)
for playlist in all_playlists:
playlists_by_provider[playlist.provider_id].append(playlist)
provider_playlists_data = {}
# 3. Fetch all Data from the provider using the get_playlist() method
for provider_id, playlists in playlists_by_provider.items():
try:
provider_client = MusicProviderRegistry.get_provider(provider_id)
except ValueError:
flash(f"Provider {provider_id} not found.", "error")
continue
combined_playlists = []
for pl in playlists:
provider_playlist = functions.get_cached_provider_playlist(pl.provider_playlist_id,pl.provider_id)
# 4. Convert the playlists to CombinedPlaylistData
combined_data = functions.prepPlaylistData(provider_playlist)
if combined_data:
combined_playlists.append(combined_data)
provider_playlists_data[provider_id] = combined_playlists
# 5. Display the resulting Groups in a template called 'monitored_playlists.html', one Heading per Provider
return render_template('monitored_playlists.html', provider_playlists_data=provider_playlists_data, title="Monitored Playlists", subtitle="Playlists which are already monitored by Jellyplist and are available immediately")
@app.route('/search')
@functions.jellyfin_login_required
def searchResults():
query = request.args.get('query')
context = {}
if query:
#iterate through every registered music provider and perform the search with it.
# Group the results by provider and display them using monitorerd_playlists.html
search_results = defaultdict(list)
for provider_id in MusicProviderRegistry.list_providers():
try:
provider_client = MusicProviderRegistry.get_provider(provider_id)
results = provider_client.search_playlist(query)
for result in results:
search_results[provider_id].append(result)
except Exception as e:
flash(f"Error fetching search results from {provider_id}: {str(e)}", "error")
# the grouped search results, must be prepared using the prepPlaylistData function
for provider_id, playlists in search_results.items():
combined_playlists = []
for pl in playlists:
combined_data = functions.prepPlaylistData(pl)
if combined_data:
combined_playlists.append(combined_data)
search_results[provider_id] = combined_playlists
context['provider_playlists_data'] = search_results
context['title'] = 'Search Results'
context['subtitle'] = 'Search results from all providers'
return render_template('monitored_playlists.html', **context)
@pl_bp.route('/track_details/<track_id>')
@functions.jellyfin_login_required
def track_details(track_id):
provider_id = request.args.get('provider')
if not provider_id:
return jsonify({'error': 'Provider not specified'}), 400
track = Track.query.filter_by(provider_track_id=track_id, provider_id=provider_id).first()
if not track:
return jsonify({'error': 'Track not found'}), 404
provider_track = functions.get_cached_provider_track(track.provider_track_id, track.provider_id)
# query also this track using the jellyfin id directly from jellyfin
if track.jellyfin_id:
jellyfin_track = jellyfin.get_item(session_token=functions._get_api_token(), item_id=track.jellyfin_id)
if jellyfin_track:
jellyfin_filesystem_path = jellyfin_track['Path']
duration_ms = provider_track.duration_ms
minutes = duration_ms // 60000
seconds = (duration_ms % 60000) // 1000
track_details = {
'title': provider_track.name,
'artist': ', '.join([artist.name for artist in provider_track.artists]),
'url': provider_track.external_urls,
'duration': f'{minutes}:{seconds:02d}',
'downloaded': track.downloaded,
'filesystem_path': track.filesystem_path,
'jellyfin_id': track.jellyfin_id,
'provider_track_id': provider_track.id,
'provider_track_url': provider_track.external_urls[0].url if provider_track.external_urls else None,
'duration_ms': duration_ms,
'download_status': track.download_status,
'provider_id': track.provider_id,
'jellyfin_filesystem_path': jellyfin_filesystem_path if track.jellyfin_id else None,
}
return render_template('partials/track_details.html', track=track_details)
@pl_bp.route('/playlist/view/<playlist_id>')
@functions.jellyfin_login_required
def get_playlist_tracks(playlist_id):
provider: MusicProviderClient = g.music_provider
playlist: base.Playlist = provider.get_playlist(playlist_id)
tracks = functions.get_tracks_for_playlist(playlist.tracks, provider_id=provider._identifier)
total_duration_ms = sum([track.duration_ms for track in tracks])
# Convert the total duration to a readable format
hours, remainder = divmod(total_duration_ms // 1000, 3600)
minutes, seconds = divmod(remainder, 60)
# Format the duration
if hours > 0:
total_duration = f"{hours}h {minutes}min"
else:
total_duration = f"{minutes}min"
return render_template(
'tracks_table.html',
tracks=tracks,
total_duration=total_duration,
track_count=len(tracks),
provider_id = provider._identifier,
item=functions.prepPlaylistData(playlist),
)
@app.route('/associate_track', methods=['POST'])
@functions.jellyfin_login_required
def associate_track():
jellyfin_id = request.form.get('jellyfin_id')
provider_track_id = request.form.get('provider_track_id')
if not jellyfin_id or not provider_track_id:
flash('Missing Jellyfin or Spotify ID')
# Retrieve the track by Spotify ID
track = Track.query.filter_by(provider_track_id=provider_track_id).first()
if not track:
flash('Track not found')
return ''
# Associate the Jellyfin ID with the track
track.jellyfin_id = jellyfin_id
track.downloaded = True
try:
# Commit the changes to the database
db.session.commit()
flash("Track associated","success")
return ''
except Exception as e:
db.session.rollback() # Roll back the session in case of an error
flash(str(e))
return ''
@app.route("/unlock_key",methods = ['POST'])
@functions.jellyfin_admin_required
def unlock_key():
key_name = request.form.get('inputLockKey')
if key_name:
tasks.task_manager.release_lock(key_name)
flash(f'Lock {key_name} released', category='success')
return ''
@app.route("/admin/getJellyfinUsers",methods = ['GET'])
@functions.jellyfin_admin_required
def get_jellyfin_users():
users = jellyfin.get_users(session_token=functions._get_api_token())
return jsonify({'users': users})
@app.route("/admin/getJellyfinPlaylistUsers",methods = ['GET'])
@functions.jellyfin_admin_required
def get_jellyfin_playlist_users():
playlist_id = request.args.get('playlist')
if not playlist_id:
return jsonify({'error': 'Playlist not specified'}), 400
users = jellyfin.get_playlist_users(session_token=functions._get_api_token(), playlist_id=playlist_id)
all_users = jellyfin.get_users(session_token=functions._get_api_token())
# extend users with the username from all_users
for user in users:
user['Name'] = next((u['Name'] for u in all_users if u['Id'] == user['UserId']), None)
# from all_users remove the users that are already in the playlist
all_users = [u for u in all_users if u['Id'] not in [user['UserId'] for user in users]]
return jsonify({'assigned_users': users, 'remaining_users': all_users})
@app.route("/admin/removeJellyfinUserFromPlaylist", methods= ['GET'])
@functions.jellyfin_admin_required
def remove_jellyfin_user_from_playlist():
playlist_id = request.args.get('playlist')
user_id = request.args.get('user')
if not playlist_id or not user_id:
return jsonify({'error': 'Playlist or User not specified'}), 400
# remove this playlist also from the user in the database
# get the playlist from the db
playlist = Playlist.query.filter_by(jellyfin_id=playlist_id).first()
user = JellyfinUser.query.filter_by(jellyfin_user_id=user_id).first()
if not user:
# Add the user to the database if they don't exist
jellyfin_user = jellyfin.get_users(session_token=functions._get_api_token(), user_id=user_id)
user = JellyfinUser(name=jellyfin_user['Name'], jellyfin_user_id=jellyfin_user['Id'], is_admin = jellyfin_user['Policy']['IsAdministrator'])
db.session.add(user)
db.session.commit()
if not playlist or not user:
return jsonify({'error': 'Playlist or User not found'}), 400
if playlist in user.playlists:
user.playlists.remove(playlist)
db.session.commit()
jellyfin.remove_user_from_playlist2(session_token=functions._get_api_token(), playlist_id=playlist_id, user_id=user_id, admin_user_id=functions._get_admin_id())
return jsonify({'success': True})
@app.route('/admin/addJellyfinUserToPlaylist')
@functions.jellyfin_admin_required
def add_jellyfin_user_to_playlist():
playlist_id = request.args.get('playlist')
user_id = request.args.get('user')
return add_jellyfin_user_to_playlist_internal(user_id, playlist_id)
def add_jellyfin_user_to_playlist_internal(user_id, playlist_id):
# assign this playlist also to the user in the database
# get the playlist from the db
playlist = Playlist.query.filter_by(jellyfin_id=playlist_id).first()
user = JellyfinUser.query.filter_by(jellyfin_user_id=user_id).first()
if not user:
# Add the user to the database if they don't exist
jellyfin_user = jellyfin.get_users(session_token=functions._get_api_token(), user_id=user_id)
user = JellyfinUser(name=jellyfin_user['Name'], jellyfin_user_id=jellyfin_user['Id'], is_admin = jellyfin_user['Policy']['IsAdministrator'])
db.session.add(user)
db.session.commit()
if not playlist or not user:
return jsonify({'error': 'Playlist or User not found'}), 400
if playlist not in user.playlists:
user.playlists.append(playlist)
db.session.commit()
if not playlist_id or not user_id:
return jsonify({'error': 'Playlist or User not specified'}), 400
jellyfin.add_users_to_playlist(session_token=functions._get_api_token(), playlist_id=playlist_id, user_id=functions._get_admin_id(), user_ids=[user_id])
return jsonify({'success': True})
@pl_bp.route('/test')
def test():
tasks.update_all_playlists_track_status()
return ''

View File

@@ -1,28 +1,36 @@
from datetime import datetime,timezone
import logging
import subprocess
from typing import List
from sqlalchemy import insert
from app import celery, app, db, functions, sp, jellyfin, jellyfin_admin_token, jellyfin_admin_id
from app import celery, app, db, functions, sp, jellyfin, jellyfin_admin_token, jellyfin_admin_id, redis_client
from app.classes import AudioProfile
from app.models import JellyfinUser,Playlist,Track, user_playlists, playlist_tracks
import os
import redis
from celery import current_task
import asyncio
import requests
from celery import current_task,signals
from celery.result import AsyncResult
redis_client = redis.StrictRedis(host='redis', port=6379, db=0)
def acquire_lock(lock_name, expiration=60):
return redis_client.set(lock_name, "locked", ex=expiration, nx=True)
from app.providers import base
from app.registry.music_provider_registry import MusicProviderRegistry
from lidarr.classes import Artist
def release_lock(lock_name):
redis_client.delete(lock_name)
@signals.celeryd_init.connect
def setup_log_format(sender, conf, **kwargs):
FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)42s() ] %(levelname)7s - %(message)s"
conf.worker_log_format = FORMAT.strip().format(sender)
conf.worker_task_log_format = FORMAT.format(sender)
@celery.task(bind=True)
def update_all_playlists_track_status(self):
lock_key = "update_all_playlists_track_status_lock"
if acquire_lock(lock_key, expiration=600):
if task_manager.acquire_lock(lock_key, expiration=600):
try:
with app.app_context():
playlists = Playlist.query.all()
@@ -37,11 +45,38 @@ def update_all_playlists_track_status(self):
for playlist in playlists:
total_tracks = 0
available_tracks = 0
app.logger.info(f"Current Playlist: {playlist.name} [{playlist.id}:{playlist.provider_playlist_id}]" )
for track in playlist.tracks:
total_tracks += 1
app.logger.debug(f"Processing track: {track.name} [{track.provider_track_id}]")
app.logger.debug(f"\tPath = {track.filesystem_path}")
if track.filesystem_path:
app.logger.debug(f"\tPath exists = {os.path.exists(track.filesystem_path)}")
app.logger.debug(f"\tJellyfinID = {track.jellyfin_id}")
if track.filesystem_path and os.path.exists(track.filesystem_path):
app.logger.info(f"Track {track.name} is already downloaded at {track.filesystem_path}.")
available_tracks += 1
track.downloaded = True
db.session.commit()
#If not found in filesystem, but a jellyfin_id is set, query the jellyfin server for the track and populate the filesystem_path from the response with the path
elif track.jellyfin_id:
jellyfin_track = jellyfin.get_item(jellyfin_admin_token, track.jellyfin_id)
app.logger.debug(f"\tJellyfin Path: {jellyfin_track['Path']}")
app.logger.debug(f"\tJellyfin Path exists: {os.path.exists(jellyfin_track['Path'])}")
if jellyfin_track and os.path.exists(jellyfin_track['Path']):
app.logger.info(f"Track {track.name} found in Jellyfin at {jellyfin_track['Path']}.")
track.filesystem_path = jellyfin_track['Path']
track.downloaded = True
db.session.commit()
available_tracks += 1
else:
track.downloaded = False
track.filesystem_path = None
db.session.commit()
else:
track.downloaded = False
track.filesystem_path = None
@@ -59,8 +94,11 @@ def update_all_playlists_track_status(self):
app.logger.info("All playlists' track statuses updated.")
return {'status': 'All playlists updated', 'total': total_playlists, 'processed': processed_playlists}
except Exception as e:
app.logger.error(f"Error downloading tracks: {str(e)}", exc_info=True)
return {'status': 'Error downloading tracks'}
finally:
release_lock(lock_key)
task_manager.release_lock(lock_key)
else:
app.logger.info("Skipping task. Another instance is already running.")
return {'status': 'Task skipped, another instance is running'}
@@ -70,90 +108,159 @@ def update_all_playlists_track_status(self):
def download_missing_tracks(self):
lock_key = "download_missing_tracks_lock"
if acquire_lock(lock_key, expiration=1800):
if task_manager.acquire_lock(lock_key, expiration=1800):
try:
app.logger.info("Starting track download job...")
with app.app_context():
spotdl_config = app.config['SPOTDL_CONFIG']
cookie_file = spotdl_config['cookie_file']
spotdl_config: dict = app.config['SPOTDL_CONFIG']
cookie_file = spotdl_config.get('cookie_file', None)
output_dir = spotdl_config['output']
client_id = app.config['SPOTIPY_CLIENT_ID']
client_secret = app.config['SPOTIPY_CLIENT_SECRET']
client_id = app.config['SPOTIFY_CLIENT_ID']
client_secret = app.config['SPOTIFY_CLIENT_SECRET']
search_before_download = app.config['SEARCH_JELLYFIN_BEFORE_DOWNLOAD']
undownloaded_tracks = Track.query.filter_by(downloaded=False).all()
# Downloading using SpotDL only works for Spotify tracks
undownloaded_tracks : List[Track] = Track.query.filter_by(downloaded=False,provider_id = "Spotify").all()
total_tracks = len(undownloaded_tracks)
if not undownloaded_tracks:
app.logger.info("No undownloaded tracks found.")
return {'status': 'No undownloaded tracks found'}
app.logger.info(f"Found {total_tracks} tracks to download.")
app.logger.debug(f"output_dir: {output_dir}")
processed_tracks = 0
failed_downloads = 0
for track in undownloaded_tracks:
app.logger.info(f"Processing track: {track.name} ({track.spotify_track_id})")
app.logger.info(f"Processing track: {track.name} [{track.provider_track_id}]")
self.update_state(state=f'[{processed_tracks}/{total_tracks}] {track.name} [{track.provider_track_id}]', meta={
'current': processed_tracks,
'total': total_tracks,
'percent': (processed_tracks / total_tracks) * 100 if processed_tracks > 0 else 0,
'failed': failed_downloads
})
# Check if the track already exists in the output directory
file_path = f"{output_dir.replace('{track-id}', track.spotify_track_id)}.mp3"
if os.path.exists(file_path):
app.logger.info(f"Track {track.name} is already downloaded at {file_path}. Marking as downloaded.")
track.downloaded = True
track.filesystem_path = file_path
if os.getenv('SPOTDL_OUTPUT_FORMAT') == '__jellyplist/{track-id}':
file_path = f"{output_dir.replace('{track-id}', track.provider_track_id)}"
else:
# if the output format is other than the default, we need to fetch the track first!
spotify_track = functions.get_cached_provider_track(track.provider_track_id, provider_id="Spotify")
# spotify_track has name, artists, album and id
# name needs to be mapped to {title}
# artist[0] needs to be mapped to {artist}
# artists needs to be mapped to {artists}
# album needs to be mapped to {album} , but needs to be checked if it is set or not, because it is Optional
# id needs to be mapped to {track-id}
# the output format is then used to create the file path
if spotify_track:
file_path = output_dir.replace("{title}",spotify_track.name)
file_path = file_path.replace("{artist}",spotify_track.artists[0].name)
file_path = file_path.replace("{artists}",",".join([artist.name for artist in spotify_track.artists]))
file_path = file_path.replace("{album}",spotify_track.album.name if spotify_track.album else "")
file_path = file_path.replace("{track-id}",spotify_track.id)
app.logger.debug(f"File path: {file_path}")
if not file_path:
app.logger.error(f"Error creating file path for track {track.name}.")
failed_downloads += 1
track.download_status = "Error creating file path"
db.session.commit()
continue
# If search_before_download is enabled, perform matching
# region search before download
if search_before_download:
app.logger.info(f"Searching for track in Jellyfin: {track.name}")
# Retrieve the Spotify track and preview URL
spotify_track = sp.track(track.spotify_track_id)
preview_url = spotify_track.get('preview_url')
if not preview_url:
app.logger.error(f"Preview URL not found for track {track.name}.")
# Decide whether to skip or proceed to download
# For now, we'll proceed to download
else:
# Get the list of Spotify artist names
spotify_artists = [artist['name'] for artist in spotify_track['artists']]
# at first try to find the track without fingerprinting it
best_match = find_best_match_from_jellyfin(track)
if best_match:
track.downloaded = True
if track.jellyfin_id != best_match['Id']:
track.jellyfin_id = best_match['Id']
app.logger.info(f"Updated Jellyfin ID for track: {track.name} ({track.provider_track_id})")
if track.filesystem_path != best_match['Path']:
track.filesystem_path = best_match['Path']
db.session.commit()
processed_tracks+=1
continue
# region search with fingerprinting
# as long as there is no endpoint found providing a preview url, we can't use this feature
# if spotify_track:
# preview_url = spotify_track.get('preview_url')
# if not preview_url:
# app.logger.error(f"Preview URL not found for track {track.name}.")
# # Decide whether to skip or proceed to download
# # For now, we'll proceed to download
# else:
# # Get the list of Spotify artist names
# spotify_artists = [artist['name'] for artist in spotify_track['artists']]
# Perform the search in Jellyfin
match_found, jellyfin_file_path = jellyfin.search_track_in_jellyfin(
session_token=jellyfin_admin_token,
preview_url=preview_url,
song_name=track.name,
artist_names=spotify_artists
)
if match_found:
app.logger.info(f"Match found in Jellyfin for track {track.name}. Skipping download.")
track.downloaded = True
track.filesystem_path = jellyfin_file_path
db.session.commit()
continue
else:
app.logger.info(f"No match found in Jellyfin for track {track.name}. Proceeding to download.")
# # Perform the search in Jellyfin
# match_found, jellyfin_file_path = jellyfin.search_track_in_jellyfin(
# session_token=jellyfin_admin_token,
# preview_url=preview_url,
# song_name=track.name,
# artist_names=spotify_artists
# )
# if match_found:
# app.logger.info(f"Match found in Jellyfin for track {track.name}. Skipping download.")
# track.downloaded = True
# track.filesystem_path = jellyfin_file_path
# db.session.commit()
# continue
# else:
# app.logger.info(f"No match found in Jellyfin for track {track.name}. Proceeding to download.")
# else:
# app.logger.warning(f"spotify_track not set, see previous log messages")
#endregion
#endregion
if file_path:
if os.path.exists(file_path):
app.logger.info(f"Track {track.name} is already downloaded at {file_path}. Marking as downloaded.")
track.downloaded = True
track.filesystem_path = file_path
db.session.commit()
continue
# Attempt to download the track using spotdl
try:
app.logger.info(f"Trying to download track: {track.name} ({track.spotify_track_id}), spotdl timeout = 90")
s_url = f"https://open.spotify.com/track/{track.spotify_track_id}"
app.logger.info(f"Trying to download track: {track.name} ({track.provider_track_id}), spotdl timeout = 90")
s_url = f"https://open.spotify.com/track/{track.provider_track_id}"
command = [
"spotdl", "download", s_url,
"--output", output_dir,
"--cookie-file", cookie_file,
"--client-id", client_id,
"--client-secret", client_secret
]
if cookie_file and os.path.exists(cookie_file):
app.logger.debug(f"Found {cookie_file}, using it for spotDL")
command.append("--cookie-file")
command.append(cookie_file)
if app.config['SPOTDL_PROXY']:
app.logger.debug(f"Using proxy: {app.config['SPOTDL_PROXY']}")
command.append("--proxy")
command.append(app.config['SPOTDL_PROXY'])
app.logger.info(f"Executing the spotDL command: {' '.join(command)}")
result = subprocess.run(command, capture_output=True, text=True, timeout=90)
if result.returncode == 0 and os.path.exists(file_path):
if result.returncode == 0:
track.downloaded = True
track.filesystem_path = file_path
app.logger.info(f"Track {track.name} downloaded successfully to {file_path}.")
if file_path:
track.filesystem_path = file_path
app.logger.info(f"Track {track.name} downloaded successfully to {file_path}.")
else:
app.logger.error(f"Download failed for track {track.name}.")
if result.stdout:
app.logger.error(f"\t stdout: {result.stdout}")
if result.stderr:
app.logger.error(f"\t stderr: {result.stderr} ")
failed_downloads += 1
track.download_status = result.stdout[:2048]
except Exception as e:
@@ -165,7 +272,7 @@ def download_missing_tracks(self):
progress = (processed_tracks / total_tracks) * 100
db.session.commit()
self.update_state(state='PROGRESS', meta={
self.update_state(state=f'[{processed_tracks}/{total_tracks}] {track.name} [{track.provider_track_id}]', meta={
'current': processed_tracks,
'total': total_tracks,
'percent': progress,
@@ -179,8 +286,16 @@ def download_missing_tracks(self):
'processed': processed_tracks,
'failed': failed_downloads
}
except Exception as e:
app.logger.error(f"Error downloading tracks: {str(e)}", exc_info=True)
return {'status': 'Error downloading tracks'}
finally:
release_lock(lock_key)
task_manager.release_lock(lock_key)
if app.config['REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK']:
libraries = jellyfin.get_libraries(jellyfin_admin_token)
for lib in libraries:
if lib['CollectionType'] == 'music':
jellyfin.refresh_library(jellyfin_admin_token, lib['ItemId'])
else:
app.logger.info("Skipping task. Another instance is already running.")
return {'status': 'Task skipped, another instance is running'}
@@ -189,11 +304,11 @@ def download_missing_tracks(self):
def check_for_playlist_updates(self):
lock_key = "check_for_playlist_updates_lock"
if acquire_lock(lock_key, expiration=600):
if task_manager.acquire_lock(lock_key, expiration=600):
try:
app.logger.info('Starting playlist update check...')
with app.app_context():
playlists = Playlist.query.all()
playlists: List[Playlist] = Playlist.query.all()
total_playlists = len(playlists)
if not playlists:
app.logger.info("No playlists found.")
@@ -204,76 +319,72 @@ def check_for_playlist_updates(self):
for playlist in playlists:
playlist.last_updated = datetime.now( timezone.utc)
sp_playlist = sp.playlist(playlist.spotify_playlist_id)
app.logger.info(f'Checking updates for playlist: {playlist.name}, s_snapshot = {sp_playlist['snapshot_id']}')
# get the correct MusicProvider from the registry
provider = MusicProviderRegistry.get_provider(playlist.provider_id)
provider_playlist = provider.get_playlist(playlist.provider_playlist_id)
provider_tracks = provider_playlist.tracks
full_update = True
app.logger.info(f'Checking updates for playlist: {playlist.name}')
db.session.commit()
if sp_playlist['snapshot_id'] == playlist.snapshot_id:
app.logger.info(f'playlist: {playlist.name} , no changes detected, snapshot_id {sp_playlist['snapshot_id']}')
continue
try:
#region Check for updates
# Fetch all playlist data from Spotify
spotify_tracks = {}
offset = 0
playlist.snapshot_id = sp_playlist['snapshot_id']
while True:
playlist_data = sp.playlist_items(playlist.spotify_playlist_id, offset=offset, limit=100)
items = playlist_data['items']
spotify_tracks.update({offset + idx: track['track'] for idx, track in enumerate(items) if track['track']})
if len(items) < 100: # No more tracks to fetch
break
offset += 100 # Move to the next batch
existing_tracks = {track.spotify_track_id: track for track in playlist.tracks}
if full_update:
existing_tracks = {track.provider_track_id: track for track in playlist.tracks}
# Determine tracks to add and remove
tracks_to_add = []
for idx, track_info in spotify_tracks.items():
if track_info:
track_id = track_info['id']
if track_id not in existing_tracks:
track = Track.query.filter_by(spotify_track_id=track_id).first()
if not track:
track = Track(name=track_info['name'], spotify_track_id=track_id, spotify_uri=track_info['uri'], downloaded=False)
db.session.add(track)
# Determine tracks to add and remove
tracks_to_add = []
for idx, track_info in enumerate(provider_tracks):
if track_info:
track_id = track_info.track.id
if track_id not in existing_tracks:
track = Track.query.filter_by(provider_track_id=track_id,provider_id = playlist.provider_id).first()
if not track:
track = Track(name=track_info.track.name, provider_track_id=track_id, provider_uri=track_info.track.uri, downloaded=False,provider_id = playlist.provider_id)
db.session.add(track)
db.session.commit()
app.logger.info(f'Added new track: {track.name}')
tracks_to_add.append((track, idx))
# else check if the track is already in the playlist and change the track_order in the playlist_tracks table
else:
app.logger.debug(f"track {track_info.track.name} moved to position {idx}")
track = existing_tracks[track_id]
stmt = playlist_tracks.update().where(playlist_tracks.c.playlist_id == playlist.id).where(playlist_tracks.c.track_id == track.id).values(track_order=idx)
db.session.execute(stmt)
db.session.commit()
app.logger.info(f'Added new track: {track.name}')
tracks_to_add.append((track, idx))
tracks_to_remove = [
existing_tracks[track_id]
for track_id in existing_tracks
if track_id not in {track['id'] for track in spotify_tracks.values() if track}
]
tracks_to_remove = [
existing_tracks[track_id]
for track_id in existing_tracks
if track_id not in {track.track.id for track in provider_tracks if track}
]
if tracks_to_add or tracks_to_remove:
playlist.last_changed = datetime.now( timezone.utc)
if tracks_to_add or tracks_to_remove:
playlist.last_changed = datetime.now( timezone.utc)
# Add and remove tracks while maintaining order
if tracks_to_add:
# Add and remove tracks while maintaining order
for track, track_order in tracks_to_add:
stmt = insert(playlist_tracks).values(
playlist_id=playlist.id,
track_id=track.id,
track_order=track_order
)
db.session.execute(stmt)
db.session.commit()
app.logger.info(f'Added {len(tracks_to_add)} tracks to playlist: {playlist.name}')
if tracks_to_add:
for track, track_order in tracks_to_add:
stmt = insert(playlist_tracks).values(
playlist_id=playlist.id,
track_id=track.id,
track_order=track_order
)
db.session.execute(stmt)
db.session.commit()
app.logger.info(f'Added {len(tracks_to_add)} tracks to playlist: {playlist.name}')
if tracks_to_remove:
for track in tracks_to_remove:
playlist.tracks.remove(track)
db.session.commit()
app.logger.info(f'Removed {len(tracks_to_remove)} tracks from playlist: {playlist.name}')
#endregion
if tracks_to_remove:
for track in tracks_to_remove:
playlist.tracks.remove(track)
db.session.commit()
app.logger.info(f'Removed {len(tracks_to_remove)} tracks from playlist: {playlist.name}')
#endregion
#region Update Playlist Items and Metadata
functions.update_playlist_metadata(playlist, sp_playlist)
functions.update_playlist_metadata(playlist, provider_playlist)
ordered_tracks = db.session.execute(
db.select(Track, playlist_tracks.c.track_order)
.join(playlist_tracks, playlist_tracks.c.track_id == Track.id)
@@ -282,6 +393,7 @@ def check_for_playlist_updates(self):
).all()
tracks = [track.jellyfin_id for track, idx in ordered_tracks if track.jellyfin_id is not None]
#jellyfin.remove_songs_from_playlist(session_token=jellyfin_admin_token, playlist_id=playlist.jellyfin_id, song_ids=tracks)
jellyfin.add_songs_to_playlist(session_token=jellyfin_admin_token, user_id=jellyfin_admin_id, playlist_id=playlist.jellyfin_id, song_ids=tracks)
#endregion
except Exception as e:
@@ -297,8 +409,11 @@ def check_for_playlist_updates(self):
app.logger.info(f"Processed {processed_playlists}/{total_playlists} playlists.")
return {'status': 'Playlist update check completed', 'total': total_playlists, 'processed': processed_playlists}
except Exception as e:
app.logger.error(f"Error downloading tracks: {str(e)}", exc_info=True)
return {'status': 'Error downloading tracks'}
finally:
release_lock(lock_key)
task_manager.release_lock(lock_key)
else:
app.logger.info("Skipping task. Another instance is already running.")
return {'status': 'Task skipped, another instance is running'}
@@ -306,62 +421,53 @@ def check_for_playlist_updates(self):
@celery.task(bind=True)
def update_jellyfin_id_for_downloaded_tracks(self):
lock_key = "update_jellyfin_id_for_downloaded_tracks_lock"
if acquire_lock(lock_key, expiration=600): # Lock for 10 minutes
full_update_key = 'full_update_jellyfin_ids_lock'
if task_manager.acquire_lock(lock_key, expiration=600): # Lock for 10 minutes
try:
app.logger.info("Starting Jellyfin ID update for downloaded tracks...")
app.logger.info("Starting Jellyfin ID update for tracks...")
with app.app_context():
downloaded_tracks = Track.query.filter_by(downloaded=True, jellyfin_id=None).all()
downloaded_tracks = Track.query.filter(
Track.downloaded == True,
Track.jellyfin_id == None,
(Track.quality_score < app.config['QUALITY_SCORE_THRESHOLD']) | (Track.quality_score == None)
).all()
if task_manager.acquire_lock(full_update_key, expiration=60*60*24):
app.logger.info(f"performing full update on jellyfin track ids. (Update tracks and playlists if better quality will be found)")
app.logger.info(f"\tQUALITY_SCORE_THRESHOLD = {app.config['QUALITY_SCORE_THRESHOLD']}")
downloaded_tracks = Track.query.filter(
(Track.quality_score < app.config['QUALITY_SCORE_THRESHOLD']) | (Track.quality_score == None)
).all()
else:
app.logger.debug(f"doing update on tracks with downloaded = True and jellyfin_id = None")
total_tracks = len(downloaded_tracks)
if not downloaded_tracks:
app.logger.info("No downloaded tracks without Jellyfin ID found.")
return {'status': 'No tracks to update'}
app.logger.info(f"Found {total_tracks} tracks to update with Jellyfin IDs.")
app.logger.info(f"Found {total_tracks} tracks to update ")
processed_tracks = 0
for track in downloaded_tracks:
app.logger.info(f"Fetching track details from Spotify: {track.name} ({track.spotify_track_id})")
search_results = jellyfin.search_music_tracks(jellyfin_admin_token,track.name)
spotify_track = None
try:
best_match = None
for result in search_results:
# if there is only one result , assume it´s the right track.
if len(search_results) == 1:
best_match = result
break
# Ensure the result is structured as expected
jellyfin_track_name = result.get('Name', '').lower()
jellyfin_artists = [artist.lower() for artist in result.get('Artists', [])]
jellyfin_path = result.get('Path','')
if jellyfin_path == track.filesystem_path:
best_match = result
break
elif not spotify_track:
try:
spotify_track = sp.track(track.spotify_track_id)
spotify_track_name = spotify_track['name']
spotify_artists = [artist['name'] for artist in spotify_track['artists']]
spotify_album = spotify_track['album']['name']
except Exception as e:
app.logger.error(f"Error fetching track details from Spotify for {track.name}: {str(e)}")
continue
# Compare name, artists, and album (case-insensitive comparison)
if (spotify_track_name.lower() == jellyfin_track_name and
set(artist.lower() for artist in spotify_artists) == set(jellyfin_artists) ):
best_match = result
break # Stop when a match is found
# Step 4: If a match is found, update jellyfin_id
best_match = find_best_match_from_jellyfin(track)
if best_match:
track.jellyfin_id = best_match['Id']
track.downloaded = True
if track.jellyfin_id != best_match['Id']:
track.jellyfin_id = best_match['Id']
app.logger.info(f"Updated Jellyfin ID for track: {track.name} ({track.provider_track_id})")
if track.filesystem_path != best_match['Path']:
track.filesystem_path = best_match['Path']
app.logger.info(f"Updated filesystem_path for track: {track.name} ({track.provider_track_id})")
track.quality_score = best_match['quality_score']
db.session.commit()
app.logger.info(f"Updated Jellyfin ID for track: {track.name} ({track.spotify_track_id})")
else:
app.logger.info(f"No matching track found in Jellyfin for {track.name}.")
app.logger.warning(f"No matching track found in Jellyfin for {track.name}.")
spotify_track = None
@@ -370,13 +476,242 @@ def update_jellyfin_id_for_downloaded_tracks(self):
processed_tracks += 1
progress = (processed_tracks / total_tracks) * 100
self.update_state(state='PROGRESS', meta={'current': processed_tracks, 'total': total_tracks, 'percent': progress})
self.update_state(state=f'{processed_tracks}/{total_tracks}: {track.name}', meta={'current': processed_tracks, 'total': total_tracks, 'percent': progress})
app.logger.info("Finished updating Jellyfin IDs for all tracks.")
return {'status': 'All tracks updated', 'total': total_tracks, 'processed': processed_tracks}
except Exception as e:
app.logger.error(f"Error updating jellyfin ids: {str(e)}", exc_info=True)
return {'status': 'Error updating jellyfin ids '}
finally:
release_lock(lock_key)
task_manager.release_lock(lock_key)
else:
app.logger.info("Skipping task. Another instance is already running.")
return {'status': 'Task skipped, another instance is running'}
@celery.task(bind=True)
def request_lidarr(self):
lock_key = "request_lidarr_lock"
if task_manager.acquire_lock(lock_key, expiration=600):
with app.app_context():
if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']:
from app import lidarr_client
try:
app.logger.info('Submitting request to Lidarr...')
# get all tracks from db
tracks = Track.query.filter_by(lidarr_processed=False).all()
total_items = len(tracks)
processed_items = 0
for track in tracks:
tfp = functions.get_cached_provider_track(track.provider_track_id, provider_id=track.provider_id)
if tfp:
if app.config['LIDARR_MONITOR_ARTISTS']:
app.logger.debug("Monitoring artists instead of albums")
# get all artists from all tracks_from_provider and unique them
artists : dict[str,base.Artist] = {}
for artist in tfp.artists:
artists[artist.name] = artist
app.logger.debug(f"Found {len(artists)} artists to monitor")
#pylint: disable=consider-using-dict-items
for artist in artists:
artist_from_lidarr = None
search_result = lidarr_client.search(artists[artist].name)
for url in artists[artist].external_urls:
artist_from_lidarr : Artist = lidarr_client.get_object_by_external_url(search_result, url.url)
if artist_from_lidarr:
app.logger.debug(f"Found artist {artist_from_lidarr.artistName} by external url {url.url}")
functions.apply_default_profile_and_root_folder(artist_from_lidarr)
try:
lidarr_client.monitor_artist(artist_from_lidarr)
track.lidarr_processed = True
db.session.commit()
except Exception as e:
app.logger.error(f"Error monitoring artist {artist_from_lidarr.artistName}: {str(e)}")
if not artist_from_lidarr:
# if the artist isnt found by the external url, search by name
artist_from_lidarr = lidarr_client.get_artists_by_name(search_result, artists[artist].name)
for artist2 in artist_from_lidarr:
functions.apply_default_profile_and_root_folder(artist2)
try:
lidarr_client.monitor_artist(artist2)
track.lidarr_processed = True
db.session.commit()
except Exception as e:
app.logger.error(f"Error monitoring artist {artist2.artistName}: {str(e)}")
processed_items += 1
self.update_state(state=f'{processed_items}/{total_items}: {artist}', meta={'current': processed_items, 'total': total_items, 'percent': (processed_items / total_items) * 100})
else:
if tfp.album:
album_from_lidarr = None
search_result = lidarr_client.search(tfp.album.name)
# if the album isnt found by the external url, search by name
album_from_lidarr = lidarr_client.get_albums_by_name(search_result, tfp.album.name)
for album2 in album_from_lidarr:
functions.apply_default_profile_and_root_folder(album2.artist)
try:
lidarr_client.monitor_album(album2)
track.lidarr_processed = True
db.session.commit()
except Exception as e:
app.logger.error(f"Error monitoring album {album2.title}: {str(e)}")
processed_items += 1
self.update_state(state=f'{processed_items}/{total_items}: {tfp.album.name}', meta={'current': processed_items, 'total': total_items, 'percent': (processed_items / total_items) * 100})
app.logger.info(f'Requests sent to Lidarr. Total items: {total_items}')
return {'status': 'Request sent to Lidarr'}
except Exception as e:
app.logger.error(f"Error downloading tracks: {str(e)}", exc_info=True)
return {'status': 'Error downloading tracks'}
finally:
task_manager.release_lock(lock_key)
else:
app.logger.info('Lidarr API key or URL not set. Skipping request.')
task_manager.release_lock(lock_key)
else:
app.logger.info("Skipping task. Another instance is already running.")
return {'status': 'Task skipped, another instance is running'}
def find_best_match_from_jellyfin(track: Track):
app.logger.debug(f"Trying to find best match from Jellyfin server for track: {track.name}")
search_results = jellyfin.search_music_tracks(jellyfin_admin_token, functions.get_longest_substring(track.name))
provider_track = None
try:
best_match = None
best_quality_score = -1 # Initialize with the lowest possible score
for result in search_results:
app.logger.debug(f"Processing search result: {result['Id']}, Path = {result['Path']}")
quality_score = compute_quality_score(result, app.config['FIND_BEST_MATCH_USE_FFPROBE'])
try:
provider_track = functions.get_cached_provider_track(track.provider_track_id, provider_id=track.provider_id)
provider_track_name = provider_track.name.lower()
provider_artists = [artist.name.lower() for artist in provider_track.artists]
except Exception as e:
app.logger.error(f"\tError fetching track details from Spotify for {track.name}: {str(e)}")
continue
jellyfin_track_name = result.get('Name', '').lower()
if len(result.get('Artists', [])) == 1:
jellyfin_artists = [a.lower() for a in result.get('Artists', [])[0].split('/')]
else:
jellyfin_artists = [artist.lower() for artist in result.get('Artists', [])]
jellyfin_album_artists = [artist['Name'].lower() for artist in result.get('AlbumArtists', [])]
if provider_track and jellyfin_track_name and jellyfin_artists and provider_artists:
app.logger.debug("\tTrack details to compare: ")
app.logger.debug(f"\t\tJellyfin-Trackname : {jellyfin_track_name}")
app.logger.debug(f"\t\t Spotify-Trackname : {provider_track_name}")
app.logger.debug(f"\t\t Jellyfin-Artists : {jellyfin_artists}")
app.logger.debug(f"\t\t Spotify-Artists : {provider_artists}")
app.logger.debug(f"\t\t Jellyfin-Alb.Art.: {jellyfin_album_artists}")
if len(search_results) == 1:
app.logger.debug(f"\tOnly 1 search_result: {result['Id']} ({app.config['JELLYFIN_SERVER_URL']}/web/#/details?id={result['Id']})")
if (provider_track_name.lower() == jellyfin_track_name and
(set(artist.lower() for artist in provider_artists) == set(jellyfin_artists) or set(jellyfin_album_artists) == set(artist.lower() for artist in provider_artists))):
app.logger.debug(f"\tQuality score for track {result['Name']}: {quality_score} [{result['Path']}]")
best_match = result
best_quality_score = quality_score
break
if (provider_track_name.lower() == jellyfin_track_name and
(set(artist.lower() for artist in provider_artists) == set(jellyfin_artists) or set(jellyfin_album_artists) == set(artist.lower() for artist in provider_artists))):
app.logger.debug(f"\tQuality score for track {result['Name']}: {quality_score} [{result['Path']}]")
if quality_score > best_quality_score:
best_match = result
best_quality_score = quality_score
# attach the quality_score to the best_match
if best_match:
best_match['quality_score'] = best_quality_score
return best_match
except Exception as e:
app.logger.error(f"Error searching Jellyfin for track {track.name}: {str(e)}")
return None
def compute_quality_score(result, use_ffprobe=False) -> float:
"""
Compute a quality score for a track based on its metadata or detailed analysis using ffprobe.
"""
score = 0
container = result.get('Container', '').lower()
if container == 'flac':
score += 100
elif container == 'wav':
score += 50
elif container == 'mp3':
score += 10
elif container == 'aac':
score += 5
if result.get('HasLyrics'):
score += 10
#runtime_ticks = result.get('RunTimeTicks', 0)
#score += runtime_ticks / 1e6
if use_ffprobe:
path = result.get('Path')
if path:
profile = AudioProfile.analyze_audio_quality_with_ffprobe(path)
ffprobe_score = profile.compute_quality_score()
score += ffprobe_score
else:
app.logger.warning(f"No valid file path for track {result.get('Name')} - Skipping ffprobe analysis.")
return score
class TaskManager:
def __init__(self):
self.tasks = {
'update_all_playlists_track_status': None,
'download_missing_tracks': None,
'check_for_playlist_updates': None,
'update_jellyfin_id_for_downloaded_tracks': None
}
if app.config['LIDARR_API_KEY']:
self.tasks['request_lidarr'] = None
def start_task(self, task_name, *args, **kwargs):
if task_name not in self.tasks:
raise ValueError(f"Task {task_name} is not defined.")
task = globals()[task_name].delay(*args, **kwargs)
self.tasks[task_name] = task.id
return task.id,'STARTED'
def get_task_status(self, task_name):
if task_name not in self.tasks:
raise ValueError(f"Task {task_name} is not defined.")
task_id = self.tasks[task_name]
if not task_id:
return {'state': 'NOT STARTED', 'info': {}, 'lock_status': False}
result = AsyncResult(task_id)
lock_status = True if self.get_lock(f"{task_name}_lock") else False
return {'state': result.state, 'info': result.info if result.info else {}, 'lock_status': lock_status}
def acquire_lock(self, lock_name, expiration=60):
return redis_client.set(lock_name, "locked", ex=expiration, nx=True)
def release_lock(self, lock_name):
redis_client.delete(lock_name)
def get_lock(self, lock_name):
return redis_client.get(lock_name)
def prepare_logger(self):
FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)20s() ] %(message)s"
logging.basicConfig(format=FORMAT)
task_manager = TaskManager()

7
app/typings.pyi Normal file
View File

@@ -0,0 +1,7 @@
from flask import g
from providers.base import MusicProviderClient
g: "Global"
class Global:
music_provider: MusicProviderClient

View File

@@ -1 +1 @@
__version__ = "0.1.2"
__version__ = "v0.1.10"

60
changelogs/0.1.6.md Normal file
View File

@@ -0,0 +1,60 @@
# Whats up in Jellyplist 0.1.6?
### 🆕Better Linking (in preparation for Lidarr integration)
During the link-task `(update_jellyfin_id_for_downloaded_tracks)`, where Jellyplist tries to link a `Spotify-Track-Id` to a `Jellyfin-Track-Id` it performs now a search and tries to find a best match from the results also considering quality aspects of a file.
You can also make use of `ffprobe`, so jellyplist get´s more detailed information about the quality profile of a file.
To use `ffprobe` set the environment variable `FIND_BEST_MATCH_USE_FFPROBE` to `true` otherwise jellyplist will use quality information provided by the Jellyfin API.
Fixes #14
In the Debug logs it will look like this:
```log
find_best_match_from_jellyfin() ] DEBUG - Quality score for track Smalltown Boy: 4410.866669999999 [/storage/media/music/Bronski Beat/The Age of Reason (2017)/CD 01/Bronski Beat - The Age of Reason - 05 - Smalltown Boy.flac]
find_best_match_from_jellyfin() ] DEBUG - Quality score for track Smalltown Boy: 4100.6 [/storage/media/music/Bronski Beat/The Age of Consent (1984)/CD 01/Bronski Beat - The Age of Consent - 06 - Smalltown Boy.flac]
find_best_match_from_jellyfin() ] DEBUG - Quality score for track Smalltown Boy: 3240.48 [/storage/media/music/__jellyplist/5vmRQ3zELMLUQPo2FLQ76x.mp3]
```
**What´s the benefit?**
Once a day, the task `update_jellyfin_id_for_downloaded_tracks` will do a full update on all tracks. This way you can listen to tracks and make use of the playlists until Lidarr provides you the same track but with better audio quality.
### 🆕Added REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK
When setting the new environment variable `REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK` to `true` , jellyplist will trigger a music library update on your Jellyfin server, in case you dont have `Realtime Monitoring` enabled on your Jellyfin library.
Fixes #10
### 🆕Removed cookies.txt requirement
No need to use `cookies.txt` file to download tracks via spotDL
>[!IMPORTANT]
> Not using a cookies.txt file will limit the bitrate of downloaded tracks to `128kbit/s` 📻
### 🆕Added LOG_LEVEL
Via the environment variable `LOG_LEVEL` you can control the log output now. The default python log levels are available:
- CRITICAL
- FATAL
- ERROR
- WARNING
- INFO
- DEBUG
- NOTSET
### 🆕Added the possibility for admins to release task lock´s
When a task will crash or whatsoever , the lock won´t be released and you have to wait for it to expire until you can run it manually. Now you can release it manually, in case you need it.
>[!IMPORTANT]
>You must be logged in as an admin
### 🆕Added the possibility for admins to remove playlists completely
This way the playlist will be removed from "monitoring" and also be removed from jellyfin.
>[!IMPORTANT]
>You must be logged in as an admin
### 🆕Allow manual track re-linking
In case something went wrong and you want to assign another Jellyfin track to a Spotify-Track-Id you can do it now manually.
Just go to "View Playlist Details", in the table where the tracks are listed, hold the `CTRL` Key while clicking on the Play from Jellyfin button. You will be presented with the search modal and can choose whatever track you like.
Fixex #13
### 🆕Added a badge on the lower left corner indicating the current version
### ⚒Overall improvements in logging
Changed log format and also added debug logging where (I think) it´s appropriate.
### 🐛 Bugfixes
- Fixed a bug where playlists weren´t updated until the `snapshot-id` of a playlist changed. Fixes #9
- Fixed a dependency error, which caused `chromaprint` fingerprinting to error out. Fixes #12
- Fixed a paging error, which caused that only the first 100 elements of a playlists were added

62
changelogs/0.1.7.md Normal file
View File

@@ -0,0 +1,62 @@
# Whats up in Jellyplist 0.1.7?
### Major overhaul
I´ve been working the past week to make this project work again, after [Spotify announced to deprecate](https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api) the playlist discover API´s , which were a crucial part of this project.
I also took this opportunity at the same time to do a major overhaul, on how Jellyplist gathers data from a music provider. Music provider API implementations must now implement defined abstract classes to work with Jellyplist, think of it like _plugins_. Jellyplist now, in theory, can gather data from any music provider - just the _plugins_ must be written. It also doesn´t matter, if it have 1,2 or 10 Music Providers to playlists. So stay tuned for more to come.
The next ones will be Deezer and YTM
After the providers will be implemented, I will at some point do the same with the media backend - so Jellyplist will be able to support other media backends like Navidrome, Plex, Emby and so on...
### 🆕New API Implementation for Spotify
As mentioned above, I needed a new way to get playlists.
Now, to get them , you don´t need an API Key to authenticate, you even don´t need to be authenticated at all. If you like to have Playlists recommended or created for you, you can use authentication via a cookie.
To do this, add a env var to you `.env` file:
```bash
SPOTIFY_COOKIE_FILE = '/jellyplist/spotify-cookie.txt'
```
And map the cookie from your local filesystem to the container path you´ve set in the `.env`file
```yaml
...
...
volumes:
- /your/local/path/open.spotify.com_cookies.txt:${SPOTIFY_COOKIE_FILE}
...
...
```
### 🆕Lidarr integration is here
To enable the Lidarr integration add these to your `.env` file
```bash
LIDARR_API_KEY = aabbccddeeffgghh11223344 # self explaining
LIDARR_URL = http://<your_lidarr_ip>:8686 # too
LIDARR_MONITOR_ARTISTS = false # If false, only the corresponding
# album will be set to monitored in lidarr, if true the whole artist
# will be set as monitored. Be careful in the beginning as you might
# hammer your lidarr instance and you indexers. Defaults to false
```
After you enabled Lidarr integration, make sure to go to _"Admin -> Lidarr"_ and set the default quality profile and music root folder.
With the Lidarr integration you get a nice workflow:
1. Add Playlist
2. Playlist gets downloaded via SpotDL and is available after some time
3. At some point (every hour on x:50) the requests to Lidarr are made.
4. Lidarr gets all files.
5. Once a day Jellyplist is doing a full update on all tracks, and searches for the same track but with a better quality profile.
### ⚠️ New required env var
Ensure to add `MUSIC_STORAGE_BASE_PATH` to your `.env` file.
```bash
MUSIC_STORAGE_BASE_PATH = '/storage/media/music' # The base path where
# your music library is located.
# Must be the same value as your music library in jellyfin
```
### ⚠️ Breaking change
As some table columns has been renamed, make sure to wipe your existing Jellyplist pgdata. Sorry for the inconvenience.
### Other changes, improvements and fixes
- UI/UX: The index page now has content. From there you can directly drop a playlist link
- UI/UX: The Search bar now works with the new API implementation
- UI/UX: A new `Browse All` (per Music Provider) Page from where you can discover playlists
- UI/UX: Check technical details on a track. Just doubleclick a row in the details view of a playlist.
- UI/UX: Allow to link a track even it´s not marked as downloaded.
- UI/UX: Reworked celery task management and the /admin/tasks UI

15
changelogs/0.1.8.md Normal file
View File

@@ -0,0 +1,15 @@
# Whats up in Jellyplist 0.1.8?
Not much this time, just some small fixes and one enhancement.
### 🆕Jellyplist now checks for updates
Jellyplist now checks the GitHub releases for new version.
If a new version is available, you will notice the small badge on the lower left will pulsate slighty, so you don´t miss any new release :smile:
If you don´t like that Jellyplist is doing this, you can opt out by setting this env var in your `.env` file
```bash
CHECK_FOR_UPDATES = false
```
### Other changes, improvements and fixes
- Fix for #30 , where the output path for spotDL wasn´t created correctly

132
changelogs/v0.1.9.md Normal file
View File

@@ -0,0 +1,132 @@
# Whats up in Jellyplist v0.1.9?
## ⚠️ BREAKING CHANGE: docker-compose.yml
>[!WARNING]
>In this release I´ve done some rework so now the setup is a bit easier, because you don´t have to spin up the -worker -beat container, these are now all in the default container and managed via supervisor. This means you have to update your `docker-compose.yml` when updating!
So now your compose file should look more or less like this
```yaml
services:
redis:
image: redis:7-alpine
container_name: redis
volumes:
- redis_data:/data
networks:
- jellyplist-network
postgres:
container_name: postgres-jellyplist
image: postgres:17.2
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PGDATA: /data/postgres
volumes:
- /jellyplist_pgdata/postgres:/data/postgres
networks:
- jellyplist-network
restart: unless-stopped
jellyplist:
container_name: jellyplist
image: ${IMAGE}
depends_on:
- postgres
- redis
ports:
- "5055:5055"
networks:
- jellyplist-network
volumes:
- /jellyplist/cookies.txt:/jellyplist/cookies.txt
- /jellyplist/open.spotify.com_cookies.txt:/jellyplist/spotify-cookie.txt
- ${MUSIC_STORAGE_BASE_PATH}:${MUSIC_STORAGE_BASE_PATH}
- /my/super/cool/storage/jellyplist/settings.yaml:/jellyplist/settings.yaml
env_file:
- .env
networks:
jellyplist-network:
driver: bridge
volumes:
postgres:
redis_data:
```
And the `.env` File
```env
IMAGE = ghcr.io/kamilkosek/jellyplist:latest
POSTGRES_USER = jellyplist
POSTGRES_PASSWORD = jellyplist
SECRET_KEY = supersecretkey # Secret key for session management
JELLYFIN_SERVER_URL = http://<jellyfin_server>:8096 # Default to local Jellyfin server
JELLYFIN_ACCESS_TOKEN = <jellyfin access token>
JELLYFIN_ADMIN_USER = <jellyfin admin username>
JELLYFIN_ADMIN_PASSWORD = <jellyfin admin password>
SPOTIFY_CLIENT_ID = <spotify client id>
SPOTIFY_CLIENT_SECRET = <spotify client secret>
JELLYPLIST_DB_HOST = postgres-jellyplist
JELLYPLIST_DB_USER = jellyplist
JELLYPLIST_DB_PASSWORD = jellyplist
LOG_LEVEL = INFO
LIDARR_API_KEY = <lidarr api key>
LIDARR_URL = http://<lidarr server>:8686
LIDARR_MONITOR_ARTISTS = false
SPOTIFY_COOKIE_FILE = '/jellyplist/spotify-cookie.txt'
MUSIC_STORAGE_BASE_PATH = '/storage/media/music'
```
### 🆕 Log Viewer
Under the `Admin` Page there is now a tab called `Logs` from where you can view the current logs, change the log-level on demand and copy a prepared markdown snippet ready to be pasted into a GitHub issue.
### 🆕 New env var´s, a bit more control over spotDL
#### `SPOTDL_PROXY`
Set a Proxy for spotDL. See [https://spotdl.readthedocs.io/en/latest/usage/#command-line-options](https://spotdl.readthedocs.io/en/latest/usage/#command-line-options)
#### `SPOTDL_OUTPUT_FORMAT`
Set the output folder and file name format for downloaded tracks via spotDL. Not all variables, which are supported by spotDL are supported by Jellyplist.
- `{title}`
- `{artist}`
- `{artists}`
- `{album}`
This way you will have a bit more controler over how the files are stored.
The complete output path is joined from `MUSIC_STORAGE_BASE_PATH` and `SPOTDL_OUTPUT_FORMAT`
_*Example:*_
`MUSIC_STORAGE_BASE_PATH = /storage/media/music`
and
`SPOTDL_OUTPUT_FORMAT = /{artist}/{album}/{title}`
The Track is _All I Want for Christmas Is You by Mariah Carey_ this will result in the following folder structure:
`/storage/media/music/Mariah Carey/Merry Christmas/All I Want for Christmas Is You.mp3`
### 🆕 Admin Users can now add Playlists to multiple Users
Sometimes I want to add a playlist to several users at once, because it´s either a _generic_ one or because my wife doesn´t want to bother with the technical stuff 😬
So now, when logged in as an admin user, when adding a playlist you can select users from your Jellyfin server which will also receive it.
Under `Admin` you can also select users which will be preselected by default. These will be stored in the file `settings.yaml`.
You can or should map this file to a file outside the container, so it will persist accross image updates (see compose sample above)
### 🆕 New `env` var `QUALITY_SCORE_THRESHOLD`
Get a better control over the `update_jellyfin_id_for_downloaded_tracks()` behaviour.
Until now this tasks performed a __full update__ every 24h: This means, every track from every playlist was searched through the Jellyfin API with the hope of finding the same track but with a better quality. While this is ok and works fine for small libraries, this tasks eats a lot of power on large libraries and also takes time.
So there is now the new `env` variable `QUALITY_SCORE_THRESHOLD` (default: `1000.0`). When a track was once found with a quality score above 1000.0, Jellyplist wont try to perform another `quality update` anymore on this track.
In order to be able to classify it a little better, here are a few common quality scores:
- spotDL downloaded track without yt-music premium: `< 300`
- spotDL downloaded track **with** yt-music premium: `< 450`
- flac `> 1000`
>[!TIP]
>Want to know what quality score (and many other details) a track has ? Just double-click the table row in the playlist details view to get all the info´s!
### Other changes, improvements and fixes
- Fix for #38 and #22 , where the manual task starting was missing a return value
- Fixed an issue where the content-type of a playlist cover image, would cause the Jellyfin API Client to fail. Thanks @artyorsh
- Fixed missing lock keys to task manager and task status rendering
- Pinned postgres version to 17.2
- Enhanced error logging in tasks
- several fixes and improvements for the Jellyfin API Client

View File

@@ -1,34 +1,65 @@
import os
import sys
import app
class Config:
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
SECRET_KEY = os.getenv('SECRET_KEY')
JELLYFIN_SERVER_URL = os.getenv('JELLYFIN_SERVER_URL')
JELLYFIN_ADMIN_USER = os.getenv('JELLYFIN_ADMIN_USER')
JELLYFIN_ADMIN_PASSWORD = os.getenv('JELLYFIN_ADMIN_PASSWORD')
SPOTIPY_CLIENT_ID = os.getenv('SPOTIPY_CLIENT_ID')
SPOTIPY_CLIENT_SECRET = os.getenv('SPOTIPY_CLIENT_SECRET')
JELLYFIN_REQUEST_TIMEOUT = int(os.getenv('JELLYFIN_REQUEST_TIMEOUT','10'))
SPOTIFY_CLIENT_ID = os.getenv('SPOTIFY_CLIENT_ID')
SPOTIFY_CLIENT_SECRET = os.getenv('SPOTIFY_CLIENT_SECRET')
SPOTIFY_COOKIE_FILE = os.getenv('SPOTIFY_COOKIE_FILE')
JELLYPLIST_DB_HOST = os.getenv('JELLYPLIST_DB_HOST')
JELLYPLIST_DB_PORT = int(os.getenv('JELLYPLIST_DB_PORT','5432'))
JELLYPLIST_DB_USER = os.getenv('JELLYPLIST_DB_USER')
JELLYPLIST_DB_PASSWORD = os.getenv('JELLYPLIST_DB_PASSWORD')
START_DOWNLOAD_AFTER_PLAYLIST_ADD = os.getenv('START_DOWNLOAD_AFTER_PLAYLIST_ADD',"true").lower() == 'true' # If a new Playlist is added, the Download Task will be scheduled immediately
START_DOWNLOAD_AFTER_PLAYLIST_ADD = os.getenv('START_DOWNLOAD_AFTER_PLAYLIST_ADD',"false").lower() == 'true' # If a new Playlist is added, the Download Task will be scheduled immediately
REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK = os.getenv('REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK',"false").lower() == 'true'
DISPLAY_EXTENDED_AUDIO_DATA = os.getenv('DISPLAY_EXTENDED_AUDIO_DATA',"false").lower() == 'true'
CACHE_TYPE = 'redis'
CACHE_REDIS_PORT = 6379
CACHE_REDIS_HOST = 'redis'
CACHE_REDIS_HOST = os.getenv('CACHE_REDIS_HOST','redis')
CACHE_REDIS_DB = 0
CACHE_DEFAULT_TIMEOUT = 3600
REDIS_URL = os.getenv('REDIS_URL','redis://redis:6379/0')
SEARCH_JELLYFIN_BEFORE_DOWNLOAD = os.getenv('SEARCH_JELLYFIN_BEFORE_DOWNLOAD',"true").lower() == 'true'
FIND_BEST_MATCH_USE_FFPROBE = os.getenv('FIND_BEST_MATCH_USE_FFPROBE','false').lower() == 'true'
SPOTIFY_COUNTRY_CODE = os.getenv('SPOTIFY_COUNTRY_CODE','DE')
LIDARR_API_KEY = os.getenv('LIDARR_API_KEY','')
LIDARR_URL = os.getenv('LIDARR_URL','')
LIDARR_MONITOR_ARTISTS = os.getenv('LIDARR_MONITOR_ARTISTS','false').lower() == 'true'
MUSIC_STORAGE_BASE_PATH = os.getenv('MUSIC_STORAGE_BASE_PATH')
CHECK_FOR_UPDATES = os.getenv('CHECK_FOR_UPDATES','true').lower() == 'true'
SPOTDL_PROXY = os.getenv('SPOTDL_PROXY',None)
SPOTDL_OUTPUT_FORMAT = os.getenv('SPOTDL_OUTPUT_FORMAT','__jellyplist/{artist}-{title}.mp3')
QUALITY_SCORE_THRESHOLD = float(os.getenv('QUALITY_SCORE_THRESHOLD',1000.0))
SEARCH_JELLYFIN_BEFORE_DOWNLOAD = True
ENABLE_DEEZER = os.getenv('ENABLE_DEEZER','false').lower() == 'true'
# SpotDL specific configuration
SPOTDL_CONFIG = {
'cookie_file': '/jellyplist/cookies.txt',
'output': '/jellyplist_downloads/__jellyplist/{track-id}',
'threads': 12
}
# combine the path provided in MUSIC_STORAGE_BASE_PATH with the SPOTDL_OUTPUT_FORMAT to get the value for output
if os.getenv('MUSIC_STORAGE_BASE_PATH'):
# Ensure MUSIC_STORAGE_BASE_PATH ends with "__jellyplist"
if not MUSIC_STORAGE_BASE_PATH.endswith("__jellyplist"):
MUSIC_STORAGE_BASE_PATH += "__jellyplist"
# Ensure SPOTDL_OUTPUT_FORMAT does not start with "/"
normalized_spotdl_output_format = SPOTDL_OUTPUT_FORMAT.lstrip("/").replace(" ", "_")
# Join the paths
output_path = os.path.join(MUSIC_STORAGE_BASE_PATH, normalized_spotdl_output_format)
SPOTDL_CONFIG.update({'output': output_path})
if SPOTIFY_COOKIE_FILE:
SPOTDL_CONFIG.update({'cookie_file': SPOTIFY_COOKIE_FILE})
@classmethod
def validate_env_vars(cls):
required_vars = {
@@ -36,12 +67,14 @@ class Config:
'JELLYFIN_SERVER_URL': cls.JELLYFIN_SERVER_URL,
'JELLYFIN_ADMIN_USER': cls.JELLYFIN_ADMIN_USER,
'JELLYFIN_ADMIN_PASSWORD': cls.JELLYFIN_ADMIN_PASSWORD,
'SPOTIPY_CLIENT_ID': cls.SPOTIPY_CLIENT_ID,
'SPOTIPY_CLIENT_SECRET': cls.SPOTIPY_CLIENT_SECRET,
'SPOTIFY_CLIENT_ID': cls.SPOTIFY_CLIENT_ID,
'SPOTIFY_CLIENT_SECRET': cls.SPOTIFY_CLIENT_SECRET,
'JELLYPLIST_DB_HOST' : cls.JELLYPLIST_DB_HOST,
'JELLYPLIST_DB_USER' : cls.JELLYPLIST_DB_USER,
'JELLYPLIST_DB_PASSWORD' : cls.JELLYPLIST_DB_PASSWORD,
'REDIS_URL': cls.REDIS_URL
'REDIS_URL': cls.REDIS_URL,
'MUSIC_STORAGE_BASE_PATH': cls.MUSIC_STORAGE_BASE_PATH
}
missing_vars = [var for var, value in required_vars.items() if not value]
@@ -49,4 +82,4 @@ class Config:
if missing_vars:
missing = ', '.join(missing_vars)
sys.stderr.write(f"Error: The following environment variables are not set: {missing}\n")
sys.exit(1)
sys.exit(1)

View File

@@ -2,13 +2,13 @@ import os
import re
import subprocess
import tempfile
from typing import Optional
import numpy as np
import requests
import base64
from urllib.parse import quote
import acoustid
import chromaprint
import logging
from jellyfin.objects import PlaylistMetadata
def _clean_query(query):
@@ -23,12 +23,18 @@ def _clean_query(query):
return cleaned_query
class JellyfinClient:
def __init__(self, base_url):
def __init__(self, base_url, timeout = 10):
"""
Initialize the Jellyfin client with the base URL of the server.
:param base_url: The base URL of the Jellyfin server (e.g., 'http://localhost:8096')
"""
self.base_url = base_url
self.timeout = timeout
self.logger = logging.getLogger(self.__class__.__name__)
self.logger.setLevel(os.getenv('LOG_LEVEL', 'INFO').upper())
FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)23s() ] %(levelname)7s - %(message)s"
logging.basicConfig(format=FORMAT)
self.logger.debug(f"Initialized Jellyfin API Client. Base = '{self.base_url}', timeout = {timeout}")
def _get_headers(self, session_token: str):
"""
@@ -46,7 +52,7 @@ class JellyfinClient:
:param password: The password of the user.
:return: Access token and user ID
"""
login_url = f'{self.base_url}/Users/AuthenticateByName'
url = f'{self.base_url}/Users/AuthenticateByName'
headers = {
'Content-Type': 'application/json',
'X-Emby-Authorization': f'MediaBrowser Client="JellyPlist", Device="Web", DeviceId="{device_id}", Version="1.0"'
@@ -55,9 +61,10 @@ class JellyfinClient:
'Username': username,
'Pw': password
}
response = requests.post(login_url, json=data, headers=headers)
self.logger.debug(f"Url={url}")
response = requests.post(url, json=data, headers=headers)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 200:
result = response.json()
return result['AccessToken'], result['User']['Id'], result['User']['Name'],result['User']['Policy']['IsAdministrator']
@@ -73,7 +80,7 @@ class JellyfinClient:
:param song_ids: A list of song IDs to include in the playlist.
:return: The newly created playlist object
"""
create_url = f'{self.base_url}/Playlists'
url = f'{self.base_url}/Playlists'
data = {
'Name': name,
'UserId': user_id,
@@ -81,8 +88,10 @@ class JellyfinClient:
'Ids': ','.join(song_ids), # Join song IDs with commas
'IsPublic' : False
}
self.logger.debug(f"Url={url}")
response = requests.post(create_url, json=data, headers=self._get_headers(session_token=session_token), timeout=10)
response = requests.post(url, json=data, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 200:
return response.json()
@@ -96,24 +105,46 @@ class JellyfinClient:
:param song_ids: A list of song IDs to include in the playlist.
:return: The updated playlist object
"""
update_url = f'{self.base_url}/Playlists/{playlist_id}/Items'
url = f'{self.base_url}/Playlists/{playlist_id}/Items'
data = {
'Ids': ','.join(song_ids) # Join song IDs with commas
}
self.logger.debug(f"Url={url}")
response = requests.post(update_url, json=data, headers=self._get_headers(session_token=session_token), timeout=10)
response = requests.post(url, json=data, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 204: # 204 No Content indicates success for updating
return {"status": "success", "message": "Playlist updated successfully"}
else:
raise Exception(f"Failed to update playlist: {response.content}")
def get_music_playlist(self, session_token : str, playlist_id: str):
"""
Get a music playlist by its ID.
:param playlist_id: The ID of the playlist to fetch.
:return: The playlist object
"""
url = f'{self.base_url}/Playlists/{playlist_id}'
self.logger.debug(f"Url={url}")
response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 200:
return response.json()
else:
raise Exception(f"Failed to get playlist: {response.content}")
def get_playlist_metadata(self, session_token: str, user_id: str,playlist_id: str) -> PlaylistMetadata:
playlist_metadata_url = f'{self.base_url}/Items/{playlist_id}'
url = f'{self.base_url}/Items/{playlist_id}'
params = {
'UserId' : user_id
}
response = requests.get(playlist_metadata_url, headers=self._get_headers(session_token=session_token), timeout=10, params = params)
self.logger.debug(f"Url={url}")
response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout, params = params)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code != 200:
raise Exception(f"Failed to fetch playlist metadata: {response.content}")
@@ -144,8 +175,11 @@ class JellyfinClient:
setattr(metadata_obj, key, value)
# Send the updated metadata to Jellyfin
update_url = f'{self.base_url}/Items/{playlist_id}'
response = requests.post(update_url, json=metadata_obj.to_dict(), headers=self._get_headers(session_token= session_token), timeout=10, params = params)
url = f'{self.base_url}/Items/{playlist_id}'
self.logger.debug(f"Url={url}")
response = requests.post(url, json=metadata_obj.to_dict(), headers=self._get_headers(session_token= session_token), timeout = self.timeout, params = params)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 204:
return {"status": "success", "message": "Playlist metadata updated successfully"}
@@ -158,37 +192,79 @@ class JellyfinClient:
Get all music playlists for the currently authenticated user.
:return: A list of the user's music playlists
"""
playlists_url = f'{self.base_url}/Items'
url = f'{self.base_url}/Items'
params = {
'IncludeItemTypes': 'Playlist', # Retrieve only playlists
'Recursive': 'true', # Include nested playlists
'Fields': 'OpenAccess' # Fields we want
}
response = requests.get(playlists_url, headers=self._get_headers(session_token=session_token), params=params , timeout = 10)
self.logger.debug(f"Url={url}")
response = requests.get(url, headers=self._get_headers(session_token=session_token), params=params , timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 200:
return response.json()['Items']
else:
raise Exception(f"Failed to get playlists: {response.content}")
def get_libraries(self, session_token: str):
url = f'{self.base_url}/Library/VirtualFolders'
params = {
}
self.logger.debug(f"Url={url}")
response = requests.get(url, headers=self._get_headers(session_token=session_token), params=params , timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 200:
return response.json()
else:
raise Exception(f"Failed to get playlists: {response.content}")
def refresh_library(self, session_token: str, library_id: str) -> bool:
url = f'{self.base_url}/Items/{library_id}/Refresh'
params = {
"Recursive": "true",
"ImageRefreshMode": "Default",
"MetadataRefreshMode": "Default",
"ReplaceAllImages": "false",
"RegenerateTrickplay": "false",
"ReplaceAllMetadata": "false"
}
self.logger.debug(f"Url={url}")
response = requests.post(url, headers=self._get_headers(session_token=session_token), params=params , timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 204:
return True
else:
raise Exception(f"Failed to update library: {response.content}")
def search_music_tracks(self, session_token: str, search_query: str):
"""
Search for music tracks by title, song name, and optionally Spotify-ID.
:param search_query: The search term (title or song name).
:return: A list of matching songs.
"""
search_url = f'{self.base_url}/Items'
url = f'{self.base_url}/Items'
params = {
'SearchTerm': _clean_query(search_query),
'SearchTerm': search_query.replace('\'',"´").replace('','´'),
'IncludeItemTypes': 'Audio', # Search only for audio items
'Recursive': 'true', # Search within all folders
'Fields': 'Name,Id,Album,Artists,Path' # Retrieve the name and ID of the song
'Fields': 'Name,Id,Album,Artists,Path', # Retrieve the name and ID of the song
'Limit': 100
}
self.logger.debug(f"Url={url}")
response = requests.get(search_url, headers=self._get_headers(session_token=session_token), params=params, timeout=10)
response = requests.get(url, headers=self._get_headers(session_token=session_token), params=params, timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 200:
return response.json()['Items']
@@ -197,26 +273,37 @@ class JellyfinClient:
def add_songs_to_playlist(self, session_token: str, user_id: str, playlist_id: str, song_ids: list[str]):
"""
Add songs to an existing playlist.
Add songs to an existing playlist in batches to prevent URL length issues.
:param playlist_id: The ID of the playlist to update.
:param song_ids: A list of song IDs to add.
:return: A success message.
"""
# Construct the API URL with query parameters
add_url = f'{self.base_url}/Playlists/{playlist_id}/Items'
params = {
'ids': ','.join(song_ids), # Comma-separated song IDs
'userId': user_id
}
# Construct the API URL without query parameters
url = f'{self.base_url}/Playlists/{playlist_id}/Items'
batch_size = 50
total_songs = len(song_ids)
self.logger.debug(f"Total songs to add: {total_songs}")
# Send the request to Jellyfin API with query parameters
response = requests.post(add_url, headers=self._get_headers(session_token=session_token), params=params, timeout=10)
for i in range(0, total_songs, batch_size):
batch = song_ids[i:i + batch_size]
params = {
'ids': ','.join(batch), # Comma-separated song IDs
'userId': user_id
}
self.logger.debug(f"Url={url} - Adding batch: {batch}")
# Check for success
if response.status_code == 204: # 204 No Content indicates success
return {"status": "success", "message": "Songs added to playlist successfully"}
else:
raise Exception(f"Failed to add songs to playlist: {response.status_code} - {response.content}")
response = requests.post(
url,
headers=self._get_headers(session_token=session_token),
params=params,
timeout=self.timeout
)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code != 204: # 204 No Content indicates success
raise Exception(f"Failed to add songs to playlist: {response.status_code} - {response.content}")
return {"status": "success", "message": "Songs added to playlist successfully"}
def remove_songs_from_playlist(self, session_token: str, playlist_id: str, song_ids):
"""
@@ -225,17 +312,25 @@ class JellyfinClient:
:param song_ids: A list of song IDs to remove.
:return: A success message.
"""
remove_url = f'{self.base_url}/Playlists/{playlist_id}/Items'
params = {
'EntryIds': ','.join(song_ids) # Join song IDs with commas
}
batch_size = 50
total_songs = len(song_ids)
self.logger.debug(f"Total songs to remove: {total_songs}")
response = requests.delete(remove_url, headers=self._get_headers(session_token=session_token), params=params, timeout=10)
for i in range(0, total_songs, batch_size):
batch = song_ids[i:i + batch_size]
url = f'{self.base_url}/Playlists/{playlist_id}/Items'
params = {
'EntryIds': ','.join(batch) # Join song IDs with commas
}
self.logger.debug(f"Url={url} - Removing batch: {batch}")
if response.status_code == 204: # 204 No Content indicates success for updating
return {"status": "success", "message": "Songs removed from playlist successfully"}
else:
raise Exception(f"Failed to remove songs from playlist: {response.content}")
response = requests.delete(url, headers=self._get_headers(session_token=session_token), params=params, timeout=self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code != 204: # 204 No Content indicates success for updating
raise Exception(f"Failed to remove songs from playlist: {response.content}")
return {"status": "success", "message": "Songs removed from playlist successfully"}
def remove_item(self, session_token: str, playlist_id: str):
"""
@@ -243,14 +338,26 @@ class JellyfinClient:
:param playlist_id: The ID of the playlist to remove.
:return: A success message upon successful deletion.
"""
remove_url = f'{self.base_url}/Items/{playlist_id}'
response = requests.delete(remove_url, headers=self._get_headers(session_token=session_token), timeout=10)
url = f'{self.base_url}/Items/{playlist_id}'
self.logger.debug(f"Url={url}")
response = requests.delete(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
logging.getLogger('requests').setLevel(logging.WARNING)
if response.status_code == 204: # 204 No Content indicates successful deletion
return {"status": "success", "message": "Playlist removed successfully"}
else:
raise Exception(f"Failed to remove playlist: {response.content}")
def get_item(self, session_token: str, item_id: str):
url = f'{self.base_url}/Items/{item_id}'
logging.getLogger('requests').setLevel(logging.WARNING)
response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
if response.status_code == 200:
return response.json()
else:
raise Exception(f"Failed to get item: {response.content}")
def remove_user_from_playlist(self, session_token: str, playlist_id: str, user_id: str):
"""
@@ -263,9 +370,11 @@ class JellyfinClient:
"""
# Construct the API endpoint URL
url = f'{self.base_url}/Playlists/{playlist_id}/Users/{user_id}'
self.logger.debug(f"Url={url}")
# Send the DELETE request to remove the user from the playlist
response = requests.delete(url, headers=self._get_headers(session_token= session_token), timeout=10)
response = requests.delete(url, headers=self._get_headers(session_token= session_token), timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 204:
# 204 No Content indicates the user was successfully removed
@@ -274,8 +383,36 @@ class JellyfinClient:
# Raise an exception if the request failed
raise Exception(f"Failed to remove user from playlist: {response.content}")
def remove_user_from_playlist2(self, session_token: str, playlist_id: str, user_id: str, admin_user_id : str):
#TODO: This is a workaround for the issue where the above method does not work
metadata = self.get_playlist_metadata(session_token= session_token, user_id= admin_user_id, playlist_id= playlist_id)
# Construct the API URL
url = f'{self.base_url}/Playlists/{playlist_id}'
users_data = []
current_users = self.get_playlist_users(session_token=session_token, playlist_id= playlist_id)
for cu in current_users:
# This way we remove the user
if cu['UserId'] != user_id:
users_data.append({'UserId': cu['UserId'], 'CanEdit': cu['CanEdit']})
data = {
'Users' : users_data
}
# Prepare the headers
headers = self._get_headers(session_token=session_token)
# Send the request to Jellyfin API
response = requests.post(url, headers=headers, json=data,timeout = self.timeout)
def set_playlist_cover_image(self, session_token: str, playlist_id: str, spotify_image_url: str):
# Check for success
if response.status_code == 204:
self.update_playlist_metadata(session_token= session_token, user_id= admin_user_id, playlist_id= playlist_id , updates= metadata)
return {"status": "success", "message": f"Users added to playlist {playlist_id}."}
else:
raise Exception(f"Failed to add users to playlist: {response.status_code} - {response.content}")
def set_playlist_cover_image(self, session_token: str, playlist_id: str, provider_image_url: str):
"""
Set the cover image of a playlist in Jellyfin using an image URL from Spotify.
@@ -285,14 +422,14 @@ class JellyfinClient:
:return: Success message or raises an exception on failure.
"""
# Step 1: Download the image from the Spotify URL
response = requests.get(spotify_image_url, timeout=10)
response = requests.get(provider_image_url, timeout = self.timeout)
if response.status_code != 200:
raise Exception(f"Failed to download image from Spotify: {response.content}")
# Step 2: Check the image content type (assume it's JPEG or PNG based on the content type from the response)
content_type = response.headers.get('Content-Type')
if content_type not in ['image/jpeg', 'image/png', 'application/octet-stream']:
content_type = response.headers.get('Content-Type').lower()
if content_type not in ['image/jpeg', 'image/png', 'image/webp', 'application/octet-stream']:
raise Exception(f"Unsupported image format: {content_type}")
# Todo:
if content_type == 'application/octet-stream':
@@ -305,11 +442,13 @@ class JellyfinClient:
headers['Content-Type'] = content_type # Set to the correct image type
headers['Accept'] = '*/*'
# Step 5: Upload the Base64-encoded image to Jellyfin as a plain string in the request body
upload_url = f'{self.base_url}/Items/{playlist_id}/Images/Primary'
# url 5: Upload the Base64-encoded image to Jellyfin as a plain string in the request body
url = f'{self.base_url}/Items/{playlist_id}/Images/Primary'
self.logger.debug(f"Url={url}")
# Send the Base64-encoded image data
upload_response = requests.post(upload_url, headers=headers, data=image_base64, timeout=10)
upload_response = requests.post(url, headers=headers, data=image_base64, timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if upload_response.status_code == 204: # 204 No Content indicates success
return {"status": "success", "message": "Playlist cover image updated successfully"}
@@ -345,7 +484,7 @@ class JellyfinClient:
headers = self._get_headers(session_token=session_token)
# Send the request to Jellyfin API
response = requests.post(url, headers=headers, json=data,timeout = 10)
response = requests.post(url, headers=headers, json=data,timeout = self.timeout)
# Check for success
if response.status_code == 204:
@@ -359,7 +498,7 @@ class JellyfinClient:
"""
me_url = f'{self.base_url}/Users/Me'
response = requests.get(me_url, headers=self._get_headers(session_token=session_token), timeout = 10)
response = requests.get(me_url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
if response.status_code == 200:
return response.json()
@@ -369,14 +508,25 @@ class JellyfinClient:
def get_playlist_users(self, session_token: str, playlist_id: str):
url = f'{self.base_url}/Playlists/{playlist_id}/Users'
response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout=10)
response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
if response.status_code != 200:
raise Exception(f"Failed to fetch playlist metadata: {response.content}")
return response.json()
def get_users(self, session_token: str, user_id: Optional[str] = None):
url = f'{self.base_url}/Users'
if user_id:
url = f'{url}/{user_id}'
response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
if response.status_code != 200:
raise Exception(f"Failed to fetch users: {response.content}")
return response.json()
def search_track_in_jellyfin(self, session_token: str, preview_url: str, song_name: str, artist_names: list):
"""
Search for a track in Jellyfin by comparing the preview audio to tracks in the library.
@@ -388,20 +538,30 @@ class JellyfinClient:
"""
try:
# Download the Spotify preview audio
self.logger.debug(f"Downloading preview {preview_url} to tmp file")
tmp = self.download_preview_to_tempfile(preview_url=preview_url)
if tmp is None:
self.logger.error(f"Downloading preview {preview_url} to tmp file failed, not continuing")
return False, None
# Convert the preview file to a normalized WAV file
self.logger.debug(f"Converting preview to WAV file")
tmp_wav = self.convert_to_wav(tmp)
if tmp_wav is None:
self.logger.error(f"Converting preview to WAV failed, not continuing")
os.remove(tmp)
return False, None
# Fingerprint the normalized preview WAV file
self.logger.debug(f"Performing fingerprinting on preview {tmp_wav}")
_, tmp_fp = acoustid.fingerprint_file(tmp_wav)
tmp_fp_dec, version = chromaprint.decode_fingerprint(tmp_fp)
tmp_fp_dec = np.array(tmp_fp_dec, dtype=np.uint32)
self.logger.debug(f"decoded fingerprint for preview: {tmp_fp_dec[:5]}")
# Search for matching tracks in Jellyfin using only the song name
search_query = song_name # Only use the song name in the search query
@@ -468,12 +628,10 @@ class JellyfinClient:
print(f"Error in search_track_in_jellyfin: {str(e)}")
return False, None
# Helper methods used in search_track_in_jellyfin
def download_preview_to_tempfile(self, preview_url):
try:
response = requests.get(preview_url, timeout=10)
response = requests.get(preview_url, timeout = self.timeout)
if response.status_code != 200:
return None
@@ -497,14 +655,18 @@ class JellyfinClient:
"-acodec", "pcm_s16le", "-ar", "44100",
"-ac", "2", output_file.name
]
result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
self.logger.error(f"Error converting to WAV, subprocess exitcode: {result.returncode} , input_file_path = {input_file_path}")
self.logger.error(f"\tprocess stdout: {result.stdout}")
self.logger.error(f"\tprocess stderr: {result.stderr}")
os.remove(output_file.name)
return None
return output_file.name
except Exception as e:
print(f"Error converting to WAV: {str(e)}")
self.logger.error(f"Error converting to WAV: {str(e)}")
return None
def sliding_fingerprint_similarity(self, full_fp, preview_fp):
@@ -533,4 +695,4 @@ class JellyfinClient:
similarity = (1 - best_score) * 100 # Convert to percentage
return similarity, best_offset
return similarity, best_offset

2
lidarr/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .client import LidarrClient
__all__ = ["LidarrClient"]

174
lidarr/classes.py Normal file
View File

@@ -0,0 +1,174 @@
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class Image:
url: str
coverType: str
extension: str
remoteUrl: str
@dataclass
class Link:
url: str
name: str
@dataclass
class Ratings:
votes: int
value: float
@dataclass
class AddOptions:
monitor: str
albumsToMonitor: List[str]
monitored: bool
searchForMissingAlbums: bool
@dataclass
class Statistics:
albumCount: int
trackFileCount: int
trackCount: int
totalTrackCount: int
sizeOnDisk: int
percentOfTracks: float
@dataclass
class Member:
name: str
instrument: str
images: List[Image]
@dataclass
class Artist:
mbId: Optional[str] = None
tadbId: Optional[int] = None
discogsId: Optional[int] = None
allMusicId: Optional[str] = None
overview: str = ""
artistType: str = ""
disambiguation: str = ""
links: List[Link] = field(default_factory=list)
nextAlbum: str = ""
lastAlbum: str = ""
images: List[Image] = field(default_factory=list)
members: List[Member] = field(default_factory=list)
remotePoster: str = ""
path: str = ""
qualityProfileId: int = 0
metadataProfileId: int = 0
monitored: bool = False
monitorNewItems: str = ""
rootFolderPath: Optional[str] = None
folder: str = ""
genres: List[str] = field(default_factory=list)
cleanName: str = ""
sortName: str = ""
tags: List[int] = field(default_factory=list)
added: str = ""
addOptions: Optional[AddOptions] = None
ratings: Optional[Ratings] = None
statistics: Optional[Statistics] = None
status : str = ""
ended : bool = False
artistName : str = ""
foreignArtistId : str = ""
id : int = 0
@dataclass
class Media:
mediumNumber: int
mediumName: str
mediumFormat: str
@dataclass
class Release:
id: int
albumId: int
foreignReleaseId: str
title: str
status: str
duration: int
trackCount: int
media: List[Media]
mediumCount: int
disambiguation: str
country: List[str]
label: List[str]
format: str
monitored: bool
@dataclass
class Album:
id: int = 0
title: str = ""
disambiguation: str = ""
overview: str = ""
artistId: int = 0
foreignAlbumId: str = ""
monitored: bool = False
anyReleaseOk: bool = False
profileId: int = 0
duration: int = 0
albumType: str = ""
secondaryTypes: List[str] = field(default_factory=list)
mediumCount: int = 0
ratings: Ratings = None
releaseDate: str = ""
releases: List[Release] = field(default_factory=list)
genres: List[str] = field(default_factory=list)
media: List[Media] = field(default_factory=list)
artist: Artist = field(default_factory=Artist)
images: List[Image] = field(default_factory=list)
links: List[Link] = field(default_factory=list)
lastSearchTime: str = ""
statistics: Statistics = None
addOptions: Optional[dict] = field(default_factory=dict)
remoteCover: str = ""
@dataclass
class RootFolder:
id: int = 0
name: str = ""
path: str = ""
defaultMetadataProfileId: int = 0
defaultQualityProfileId: int = 0
defaultMonitorOption: str = ""
defaultNewItemMonitorOption: str = ""
defaultTags: List[int] = field(default_factory=list)
accessible: bool = False
freeSpace: int = 0
totalSpace: int = 0
@dataclass
class Quality:
id: int = 0
name: str = ""
@dataclass
class Item:
id: int = 0
name: str = ""
quality: Quality = field(default_factory=Quality)
items: List[str] = field(default_factory=list)
allowed: bool = False
@dataclass
class FormatItem:
id: int = 0
format: int = 0
name: str = ""
score: int = 0
@dataclass
class QualityProfile:
id: int = 0
name: str = ""
upgradeAllowed: bool = False
cutoff: int = 0
items: List[Item] = field(default_factory=list)
minFormatScore: int = 0
cutoffFormatScore: int = 0
formatItems: List[FormatItem] = field(default_factory=list)

143
lidarr/client.py Normal file
View File

@@ -0,0 +1,143 @@
import json
import re
from flask import jsonify
import requests
from typing import List, Optional
from .classes import Album, Artist, QualityProfile, RootFolder
import logging
l = logging.getLogger(__name__)
class LidarrClient:
def __init__(self, base_url: str, api_token: str):
self.base_url = base_url
self.api_token = api_token
self.headers = {
'X-Api-Key': self.api_token
}
def _get(self, endpoint: str, params: Optional[dict] = None):
response = requests.get(f"{self.base_url}{endpoint}", headers=self.headers, params=params)
response.raise_for_status()
return response.json()
def _post(self, endpoint: str, json: dict):
response = requests.post(f"{self.base_url}{endpoint}", headers=self.headers, json=json)
response.raise_for_status()
return response.json()
def _put(self, endpoint: str, json: dict):
response = requests.put(f"{self.base_url}{endpoint}", headers=self.headers, json=json)
response.raise_for_status()
return response.json()
def get_album(self, album_id: int) -> Album:
l.debug(f"Getting album {album_id}")
data = self._get(f"/api/v1/album/{album_id}")
return Album(**data)
def get_artist(self, artist_id: int) -> Artist:
l.debug(f"Getting artist {artist_id}")
data = self._get(f"/api/v1/artist/{artist_id}")
return Artist(**data)
def search(self, term: str) -> List[object]:
l.debug(f"Searching for {term}")
data = self._get("/api/v1/search", params={"term": term})
results = []
for item in data:
if 'artist' in item:
results.append(Artist(**item['artist']))
elif 'album' in item:
results.append(Album(**item['album']))
return results
# A method which takes a List[object] end external URL as parameter, and returns the object from the List[object] which has the same external URL as the parameter.
def get_object_by_external_url(self, objects: List[object], external_url: str) -> object:
l.debug(f"Getting object by external URL {external_url}")
# We need to check whether the external_url matches intl-[a-zA-Z]{2}\/ it has to be replaced by an empty string
external_url = re.sub(r"intl-[a-zA-Z]{2}\/", "", external_url)
for obj in objects:
# object can either be an Album or an Artist, so it can be verified and casted
if isinstance(obj, Album):
for link in obj.links:
if link['url'] == external_url:
return obj
elif isinstance(obj, Artist):
for link in obj.links:
if link['url'] == external_url:
return obj
return None
# A method to get all Albums from List[object] where the name equals the parameter name
def get_albums_by_name(self, objects: List[object], name: str) -> List[Album]:
l.debug(f"Getting albums by name {name}")
albums = []
for obj in objects:
if isinstance(obj, Album) and obj.title == name:
artist = Artist(**obj.artist)
obj.artist = artist
albums.append(obj)
return albums
# a method to get all artists from List[object] where the name equals the parameter name
def get_artists_by_name(self, objects: List[object], name: str) -> List[Artist]:
l.debug(f"Getting artists by name {name}")
artists = []
for obj in objects:
if isinstance(obj, Artist) and obj.artistName == name:
artists.append(obj)
return artists
def create_album(self, album: Album) -> Album:
l.debug(f"Creating album {album.title}")
json_artist = album.artist.__dict__
album.artist = json_artist
data = self._post("/api/v1/album", json=album.__dict__)
return Album(**data)
def update_album(self, album_id: int, album: Album) -> Album:
l.debug(f"Updating album {album_id}")
json_artist = album.artist.__dict__
album.artist = json_artist
data = self._put(f"/api/v1/album/{album_id}", json=album.__dict__)
return Album(**data)
def create_artist(self, artist: Artist) -> Artist:
l.debug(f"Creating artist {artist.artistName}")
data = self._post("/api/v1/artist", json=artist.__dict__)
return Artist(**data)
def update_artist(self, artist_id: int, artist: Artist) -> Artist:
l.debug(f"Updating artist {artist_id}")
data = self._put(f"/api/v1/artist/{artist_id}", json=artist.__dict__)
return Artist(**data)
# shorthand method to set artist to monitored
def monitor_artist(self, artist: Artist):
artist.monitored = True
l.debug(f"Monitoring artist {artist.artistName}")
if artist.id == 0:
artist = self.create_artist(artist)
else:
self.update_artist(artist.id, artist)
# shorthand method to set album to monitored
def monitor_album(self, album: Album):
album.monitored = True
l.debug(f"Monitoring album {album.title}")
if album.id == 0:
album = self.create_album(album)
else:
self.update_album(album.id, album)
# a method to query /api/v1/rootfolder and return a List[RootFolder]
def get_root_folders(self) -> List[RootFolder]:
l.debug("Getting root folders")
data = self._get("/api/v1/rootfolder")
return [RootFolder(**folder) for folder in data]
# a method to query /api/v1/qualityprofile and return a List[QualityProfile]
def get_quality_profiles(self) -> List[QualityProfile]:
l.debug("Getting quality profiles")
data = self._get("/api/v1/qualityprofile")
return [QualityProfile(**profile) for profile in data]

View File

@@ -0,0 +1,70 @@
"""refacotring db to work with multiple music provider
Revision ID: 18d056f49f59
Revises: d4fef99d5d3c
Create Date: 2024-11-29 22:51:41.271688
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '18d056f49f59'
down_revision = 'd4fef99d5d3c'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlist', schema=None) as batch_op:
batch_op.add_column(sa.Column('provider_playlist_id', sa.String(length=120), nullable=False))
batch_op.add_column(sa.Column('provider_uri', sa.String(length=120), nullable=False))
batch_op.add_column(sa.Column('provider_id', sa.String(length=20), nullable=True))
batch_op.drop_constraint('playlist_spotify_playlist_id_key', type_='unique')
batch_op.drop_constraint('playlist_spotify_uri_key', type_='unique')
batch_op.create_unique_constraint(None, ['provider_uri'])
batch_op.create_unique_constraint(None, ['provider_playlist_id'])
batch_op.drop_column('spotify_playlist_id')
batch_op.drop_column('spotify_uri')
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.add_column(sa.Column('provider_track_id', sa.String(length=120), nullable=False))
batch_op.add_column(sa.Column('provider_uri', sa.String(length=120), nullable=False))
batch_op.add_column(sa.Column('provider_id', sa.String(length=20), nullable=True))
batch_op.drop_constraint('track_spotify_track_id_key', type_='unique')
batch_op.drop_constraint('track_spotify_uri_key', type_='unique')
batch_op.create_unique_constraint(None, ['provider_track_id'])
batch_op.create_unique_constraint(None, ['provider_uri'])
batch_op.drop_column('spotify_track_id')
batch_op.drop_column('spotify_uri')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.add_column(sa.Column('spotify_uri', sa.VARCHAR(length=120), autoincrement=False, nullable=False))
batch_op.add_column(sa.Column('spotify_track_id', sa.VARCHAR(length=120), autoincrement=False, nullable=False))
batch_op.drop_constraint(None, type_='unique')
batch_op.drop_constraint(None, type_='unique')
batch_op.create_unique_constraint('track_spotify_uri_key', ['spotify_uri'])
batch_op.create_unique_constraint('track_spotify_track_id_key', ['spotify_track_id'])
batch_op.drop_column('provider_id')
batch_op.drop_column('provider_uri')
batch_op.drop_column('provider_track_id')
with op.batch_alter_table('playlist', schema=None) as batch_op:
batch_op.add_column(sa.Column('spotify_uri', sa.VARCHAR(length=120), autoincrement=False, nullable=False))
batch_op.add_column(sa.Column('spotify_playlist_id', sa.VARCHAR(length=120), autoincrement=False, nullable=False))
batch_op.drop_constraint(None, type_='unique')
batch_op.drop_constraint(None, type_='unique')
batch_op.create_unique_constraint('playlist_spotify_uri_key', ['spotify_uri'])
batch_op.create_unique_constraint('playlist_spotify_playlist_id_key', ['spotify_playlist_id'])
batch_op.drop_column('provider_id')
batch_op.drop_column('provider_uri')
batch_op.drop_column('provider_playlist_id')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""Add quality score to Track
Revision ID: 2777a1885a6b
Revises: 46a65ecc9904
Create Date: 2024-12-11 20:02:00.303765
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2777a1885a6b'
down_revision = '46a65ecc9904'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.add_column(sa.Column('quality_score', sa.Float(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.drop_column('quality_score')
# ### end Alembic commands ###

View File

@@ -0,0 +1,38 @@
"""Change track name lenght maximum to 200
Revision ID: 46a65ecc9904
Revises: d13088ebddc5
Create Date: 2024-12-11 19:35:47.617811
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '46a65ecc9904'
down_revision = 'd13088ebddc5'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.alter_column('name',
existing_type=sa.VARCHAR(length=150),
type_=sa.String(length=200),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.alter_column('name',
existing_type=sa.String(length=200),
type_=sa.VARCHAR(length=150),
existing_nullable=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""Add lidarr_processed flag
Revision ID: d13088ebddc5
Revises: 18d056f49f59
Create Date: 2024-12-03 22:44:21.287754
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd13088ebddc5'
down_revision = '18d056f49f59'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.add_column(sa.Column('lidarr_processed', sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.drop_column('lidarr_processed')
# ### end Alembic commands ###

111
readme.md
View File

@@ -1,7 +1,7 @@
![Jellyplist Logo](./static/images/logo_large.png)
> [!WARNING]
> Jellyplist is still at a very early stage: expect Bugs and weird behaviour
> Jellyplist is still at a very early stage: expect Bugs and weird behaviour. Especially the UI and UX are a bit clunky and unresponsive
## What is Jellyplist ?
Jellyplist aims to be a companion app for your self-hosted [Jellyfin](https://jellyfin.org/) Server. With Jellyplist you will be able to replicate/sync playlists from Spotify to your local Jellyfin account. Under the hood, it uses [SpotDL](https://spotdl.readthedocs.io/en/latest/) for downloading the corresponding tracks from the available sources if a track isn´t found in your local library.
@@ -9,32 +9,59 @@ Jellyplist aims to be a companion app for your self-hosted [Jellyfin](https://je
It´s definitely not a general Playlist Manager for Jellyfin.
## Features
- **Discover Playlists**: Use well-known *Featured Playlists* listings.
- **Categories**: Browse playlists by categories
- **Discover Playlists**: Browse playlists like its nothing.
- **View Monitored Playlists**: View playlists which are already synced by the server, adding these to your Jellyfin account will make them available immediately
- **Search Playlist**: Search for playlists
- **No Sign-Up or User-Accounts**: Jellyplist uses your local Jellyfin server for authentication
- **Automatically keep track of changes**: Changes in order, added or removed songs will be tracked and synced with Jellyfin.
- **Metadata Sync**: Playlist Metadata will be available at your Jellyfin Server
- **Lidarr Integrations**: Automatically submit Artists or only Albums to your Lidarr instance
- **Automatic Quality Upgrades**: When the same track from a playlist is added later with better quality, the playlist in Jellyfin will be updated to use the better sounding track.
## Getting Started
The easiest way to start is by using docker and compose.
1. Log in on https://developers.spotify.com/. Go to the dashboard, create an app and get your Client ID and Secret
2. Get your [cookies.txt file for spot-dl ](https://spotdl.readthedocs.io/en/latest/usage/#youtube-music-premium)
> [!IMPORTANT]
> Currently a [youtube premium account](https://spotdl.readthedocs.io/en/latest/usage/#youtube-music-premium) is required, the next release will mitigate this.
3. Prepare a `.env` File
2. Get your [cookies.txt file for spot-dl ](https://spotdl.readthedocs.io/en/latest/usage/#youtube-music-premium) if you want downloaded files to have 256kbit/s, otherwise 128kbit/s
3. Get your cookie-file from open.spotify.com , this works the same way as in step 2.
4. Prepare a `.env` File
```
IMAGE = ghcr.io/kamilkosek/jellyplist:latest
POSTGRES_USER = jellyplist
POSTGRES_PASSWORD = jellyplist
SECRET_KEY = Keykeykesykykesky # Secret key for session management
JELLYFIN_SERVER_URL = http://192.168.178.14:8096 # local Jellyfin server
JELLYFIN_ADMIN_USER = admin # due to api limitations jellyplist uses user authentication rather than api tokens
JELLYFIN_ADMIN_PASSWORD = admin_password_for_your_jellyifn_admin
SPOTIPY_CLIENT_ID = <Client ID from Step 1>
SPOTIPY_CLIENT_SECRET = <Secret from Step 1>
SPOTIFY_CLIENT_ID = <Client ID from Step 1>
SPOTIFY_CLIENT_SECRET = <Secret from Step 1>
JELLYPLIST_DB_HOST = postgres-jellyplist #Hostname of the db Container
JELLYPLIST_DB_USER = jellyplist
JELLYPLIST_DB_PASSWORD = jellyplist
MUSIC_STORAGE_BASE_PATH = '/storage/media/music' # The base path where your music library is located. Must be the same value as your music library in jellyfin
### Optional:
# SPOTDL_PROXY = http://proxy:8080
# SPOTDL_OUTPUT_FORMAT = "/{artist}/{artists} - {title}" # Supported variables: {title}, {artist},{artists}, {album}, Will be joined with to get a complete path
# SEARCH_JELLYFIN_BEFORE_DOWNLOAD = false # defaults to true, before attempting to do a download with spotDL , the song will be searched first in the local library ("true" MAY INCURE PERFORMENCE ISSUES)
# START_DOWNLOAD_AFTER_PLAYLIST_ADD = true # defaults to false, If a new Playlist is added, the Download Task will be scheduled immediately
# FIND_BEST_MATCH_USE_FFPROBE = true # Use ffprobe to gather quality details from a file to calculate quality score. Otherwise jellyplist will use details provided by jellyfin. defaults to false.
#REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK = true # jellyplist will trigger a music library update on your Jellyfin server, in case you dont have `Realtime Monitoring` enabled on your Jellyfin library. Defaults to false. ("true" MAY INCURE PERFORMENCE ISSUES)
# LOG_LEVEL = DEBUG # Defaults to INFO
# SPOTIFY_COOKIE_FILE = '/jellyplist/spotify-cookie.txt' # Not necesarily needed, but if you like to browse your personal recomendations you must provide it so that the new api implementation is able to authenticate
### Lidarr integration
# LIDARR_API_KEY = aabbccddeeffgghh11223344 # self explaining
# LIDARR_URL = http://<your_lidarr_ip>:8686 # too
# LIDARR_MONITOR_ARTISTS = false # If false, only the corresponding album will be set to monitored in lidarr, if true the whole artist will be set as monitored. Be careful in the beginning as you might hammer your lidarr instance and you indexers. Defaults to false
```
4. Prepare a `docker-compose.yml`
@@ -49,13 +76,13 @@ services:
- jellyplist-network
postgres:
container_name: postgres-jellyplist
image: postgres
image: postgres:17.2
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PGDATA: /data/postgres
volumes:
- postgres:/data/postgres
- /jellyplist_pgdata/postgres:/data/postgres
ports:
- "5432:5432"
networks:
@@ -64,7 +91,7 @@ services:
jellyplist:
container_name: jellyplist
image: ghcr.io/kamilkosek/jellyplist:latest
image: ${IMAGE}
depends_on:
- postgres
- redis
@@ -73,49 +100,18 @@ services:
networks:
- jellyplist-network
volumes:
# Map Your cookies.txt file to exac
- /your/local/path/cookies.txt:/jellyplist/cookies.txt
- /storage/media/music:/jellyplist_downloads
- /jellyplist/cookies.txt:/jellyplist/cookies.txt
- /jellyplist/open.spotify.com_cookies.txt:/jellyplist/spotify-cookie.txt
- ${MUSIC_STORAGE_BASE_PATH}:${MUSIC_STORAGE_BASE_PATH}
env_file:
- .env
# The jellyplist-worker is used to perform background tasks, such as downloads and playlist updates.
# It is the same container, but with a different command
jellyplist-worker:
container_name: jellyplist-worker
image: ghcr.io/kamilkosek/jellyplist:latest
command: ["celery", "-A", "app.celery", "worker", "--loglevel=info"]
volumes:
# Map Your cookies.txt file to exac
- /your/local/path/cookies.txt:/jellyplist/cookies.txt
- /storage/media/music:/jellyplist_downloads
env_file:
- .env
depends_on:
- postgres
- redis
networks:
- jellyplist-network
# jellyplist-beat is used to schedule the background tasks
jellyplist-beat:
container_name: jellyplist-beat
image: ghcr.io/kamilkosek/jellyplist:latest
command: ["celery", "-A", "app.celery", "beat", "--loglevel=info"]
env_file:
- .env
depends_on:
- postgres
- redis
networks:
- jellyplist-network
networks:
jellyplist-network:
driver: bridge
volumes:
postgres:
pgadmin:
redis_data:
```
5. Start your stack with `docker compose up -d`
@@ -124,6 +120,7 @@ volumes:
## Technical Details/FAQ
- _Why have I to provide a Jellyfin Admin and Password instead of a API Token ?_
Its because of some limitations in the Jellyfin API. The goal of Jellyplist was to always maintain only one copy of a playlist in Jellyfin and to use SharedPlaylists which are "owned" by one admin user.
@@ -158,7 +155,25 @@ Jellyplist will cache requests where possible. Especially the `/tracks` endpoint
- When logged in as admin, you will see the admin section in the sidebar. From there you can some kind of `batch linking`. All unlinked tracks will be displayed at once.
> [!TIP]
> Linking of tracks had to be done only once. If a different playlist has the same track, Jellyplist will reuse the link
![Jellyplist batch link](./screenshots/batch_link.png)
> [!TIP]
> Linking of tracks had to be done only once. If a different playlist has the same track, Jellyplist will reuse the link
#### After you added your first playlist´s, the worker and scheduler will take over from this point.
The default schedules are:
| **Schedule Name** | **Task** | **Schedule** |
|--------------------------------------------|----------------------------------------------|----------------------|
| `download-missing-tracks-schedule` | `app.tasks.download_missing_tracks` | Every day at minute 30 |
| `check-playlist-updates-schedule` | `app.tasks.check_for_playlist_updates` | Every day at minute 25 |
| `update_all_playlists_track_status-schedule`| `app.tasks.update_all_playlists_track_status`| Every 2 minutes |
| `update_jellyfin_id_for_downloaded_tracks-schedule` | `app.tasks.update_jellyfin_id_for_downloaded_tracks` | Every 10 minutes |
For now the schedules aren´t configurable, but this is subject to change.
> [!TIP]
> Please be patient after you added your first batch of playlists! Jellyplist currently processes one track at a time, and this means it can take some time for you to see the first results.
Then let Jellyplist do it´s work, after some time you should be able to see the playlist in Jellyfin.
Have Fun ✌🏽

View File

@@ -9,12 +9,17 @@ numpy==2.1.3
pyacoustid==1.3.0
redis==5.1.1
Requests==2.32.3
spotdl==4.2.10
spotdl==4.2.11
spotipy==2.24.0
SQLAlchemy==2.0.35
Unidecode==1.3.8
chromaprint
psycopg2-binary
eventlet
pydub
fuzzywuzzy
fuzzywuzzy
pyyaml
click
pycryptodomex
mutagen
requests
deezer-py

View File

@@ -3,16 +3,18 @@ body {
}
.sidebar {
background-color: #1a1d21;
background-color: #1a1d21;
height: 100vh;
padding-top: 20px;
padding-left: 10px;
color: white;
}
.top-bar {
background-color: #1a1d21;
background-color: #1a1d21;
}
.sidebar h3 {
color: white;
padding-left: 15px;
@@ -50,6 +52,76 @@ body {
width: 140px;
margin-right: 10px;
}
.logo img{
.logo img {
width: 100%;
}
}
@media screen and (min-width: 1600px) {
.modal-xl {
max-width: 90%;
/* New width for default modal */
}
}
.searchbar {
margin-bottom: auto;
margin-top: auto;
height: 60px;
background-color: #353b48;
border-radius: 30px;
padding: 10px;
}
.search_input {
color: white;
border: 0;
outline: 0;
background: none;
width: 450px;
caret-color: transparent;
line-height: 40px;
transition: width 0.4s linear;
}
.searchbar:hover>.search_input {
/* padding: 0 10px; */
width: 450px;
caret-color: red;
/* transition: width 0.4s linear; */
}
.searchbar:hover>.search_icon {
background: white;
color: #e74c3c;
}
.search_icon {
height: 40px;
width: 40px;
float: right;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
color: white;
text-decoration: none;
}
.btn-pulsing {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.08);
}
100% {
transform: scale(1);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -5,14 +5,14 @@ var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
})
// Function to open the search modal and trigger the search automatically
function openSearchModal(trackTitle, spotify_id) {
function openSearchModal(trackTitle, provider_track_id) {
const modal = new bootstrap.Modal(document.getElementById('searchModal'));
const searchQueryInput = document.getElementById('search-query');
const spotifyIdInput = document.getElementById('spotify-id');
const providerTrackIdInput = document.getElementById('provider-track-id');
// Pre-fill the input fields
searchQueryInput.value = trackTitle;
spotifyIdInput.value = spotify_id;
providerTrackIdInput.value = provider_track_id;
// Show the modal
modal.show();
@@ -83,4 +83,14 @@ function playJellyfinTrack(button, jellyfinId) {
};
})
.catch(error => console.error('Error fetching Jellyfin stream URL:', error));
}
function handleJellyfinClick(event, jellyfinId, trackTitle, providerTrackId) {
if (event.ctrlKey) {
// CTRL key is pressed, open the search modal
openSearchModal(trackTitle, providerTrackId);
} else {
// CTRL key is not pressed, play the track
playJellyfinTrack(event.target, jellyfinId);
}
}

35
supervisord.conf Normal file
View File

@@ -0,0 +1,35 @@
[supervisord]
nodaemon=true
[program:jellyplist]
command=python run.py
autostart=true
autorestart=true
stdout_events_enabled=true
stderr_events_enabled=true
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
[program:celery_worker]
command=celery -A app.celery worker
autostart=true
autorestart=true
stdout_events_enabled=true
stderr_events_enabled=true
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
[program:celery_beat]
command=celery -A app.celery beat
autostart=true
autorestart=true
stdout_events_enabled=true
stderr_events_enabled=true
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

View File

@@ -4,13 +4,23 @@
<nav class="navbar navbar-expand-lg navbar-dark border-bottom mb-2">
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item active">
<li class="nav-item">
<a class="nav-link" href="/admin/settings">Settings
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/link_issues">Link Issues
{% include 'partials/_unlinked_tracks_badge.html' %}</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/tasks">Tasks</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/lidarr">Lidarr</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/logs?name=logs">Logs</a>
</li>
</ul>
</div>

View File

@@ -0,0 +1,32 @@
{% extends "admin.html" %}
{% block admin_content %}
<div class="container mt-5">
<h1>Lidarr Configuration</h1>
{% if error %}
<div class="alert alert-danger" role="alert">
{{ error }}
</div>
{% else %}
<form id="lidarrConfigForm" method="POST" action="{{ url_for('save_lidarr_config') }}">
<div class="mb-3">
<label for="qualityProfile" class="form-label">Default Quality Profile</label>
<select class="form-select" id="qualityProfile" name="qualityProfile" required>
{% for profile in quality_profiles %}
<option value="{{ profile.id }}" {% if profile.id == current_quality_profile|int %}selected{% endif %}>{{ profile.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="rootFolder" class="form-label">Default Root Folder</label>
<select class="form-select" id="rootFolder" name="rootFolder" required>
{% for folder in root_folders %}
<option value="{{ folder.path }}" {% if folder.path == current_root_folder %}selected{% endif %}>{{ folder.path }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,163 @@
{% extends "admin.html" %}
{% block admin_content %}
{% if not logs %}
{% set logs = "Logfile empty or not found" %}
{% endif %}
{% set log_level = config['LOG_LEVEL'] %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.0/min/vs/loader.js"></script>
<div class="container-fluid mt-5">
<h1>Log Viewer</h1>
<div class="mb-3 row">
<form action="/admin/setloglevel" method="post" class="d-inline">
<label for="logLevel" class="form-label">Log Level</label>
<select class="form-select" id="logLevel" name="logLevel" required aria-describedby="loglevelHelp">
<option value="DEBUG" {% if log_level=="DEBUG" %}selected{% endif %}>DEBUG</option>
<option value="INFO" {% if log_level=="INFO" %}selected{% endif %}>INFO</option>
<option value="WARNING" {% if log_level=="WARNING" %}selected{% endif %}>WARNING</option>
<option value="ERROR" {% if log_level=="ERROR" %}selected{% endif %}>ERROR</option>
<option value="CRITICAL" {% if log_level=="CRITICAL" %}selected{% endif %}>CRITICAL</option>
</select>
<div id="loglevelHelp" class="form-text">Set the log level on demand.</div>
<button type="submit" class="btn btn-primary mt-2">Set Log Level</button>
</form>
</div>
<div class="mb-5 mt-3 row">
<button type="button" class="btn btn-warning" onclick="openCreateIssueModal()">Get Logs for a new Issue</button>
<!-- Modal HTML -->
<div class="modal fade" id="createIssueModal" tabindex="-1" aria-labelledby="createIssueModalLabel"
aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createIssueModalLabel">Create Issue</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<span class="m-2">Hit the copy button or copy this text manually and paste it to your GitHub
Issue.</span>
<div id="issue-text" style="height: 400px;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" id="copyText" class="btn btn-primary">Copy</button>
</div>
<script>
async function setClipboard(text) {
const type = "text/plain";
const blob = new Blob([text], { type });
const data = [new ClipboardItem({ [type]: blob })];
await navigator.clipboard.write(data);
}
document.getElementById('copyText').addEventListener('click', function () {
const issueEditor = monaco.editor.getModels()[1];
const issueText = issueEditor.getValue();
if(!window.isSecureContext){
alert('Clipboard API is not available in insecure context. Please use a secure context (HTTPS) or just copy the text manually.');
return;
}
setClipboard(issueText);
});
</script>
</div>
</div>
</div>
</div>
<div class="mb-5 mt-3 row">
<label for="logType" class="form-label">Select Logs</label>
<select class="form-select" id="logType" name="logType" required
onchange="location.href='/admin/logs?name=' + this.value;">
<option value="logs" {% if name=="logs" %}selected{% endif %}>Logs</option>
<option value="worker" {% if name=="worker" %}selected{% endif %}>Worker Logs</option>
<option value="beat" {% if name=="beat" %}selected{% endif %}>Beat Logs</option>
</select>
</div>
<div class="mt-3 row" id="editor" style="height: 700px;">
</div>
<script>
require.config({ paths: { 'vs': 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.0/min/vs' } });
require(['vs/editor/editor.main'], function () {
monaco.languages.register({ id: "jellyplistLog" });
// Register a tokens provider for the language
monaco.languages.setMonarchTokensProvider("jellyplistLog", {
tokenizer: {
root: [
[/ERROR -.*/, "custom-error"],
[/WARNING -/, "custom-notice"],
[/INFO -/, "custom-info"],
[/DEBUG -.*/, "custom-debug"],
[/^\[\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2},\d{3}\]/, "custom-date"],
[/\s.*[a-zA-Z0-9_]+\.[a-z]{2,4}(?=:)/, "custom-filename"],
[/\d+(?= -)/, "custom-lineno"]
],
},
});
monaco.editor.defineTheme("jellyplistLogTheme", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "custom-info", foreground: "808080" },
{ token: "custom-error", foreground: "ff0000", fontStyle: "bold" },
{ token: "custom-notice", foreground: "FFA500" },
{ token: "custom-debug", foreground: "851ea5" },
{ token: "custom-date", foreground: "90cc6f" },
{ token: "custom-filename", foreground: "d9d04f", fontStyle: "italic" },
{ token: "custom-lineno", foreground: "d9d04f", fontStyle: "light" },
],
colors: {
"editor.foreground": "#ffffff",
},
});
let editor = monaco.editor.create(document.getElementById('editor'), {
value: `{{logs | safe }}`,
language: 'jellyplistLog',
readOnly: true,
minimap: { enabled: false },
theme: 'jellyplistLogTheme',
automaticLayout: true
});
editor.revealLine(editor.getModel().getLineCount())
});
function openCreateIssueModal() {
const modal = new bootstrap.Modal(document.getElementById('createIssueModal'));
fetch('/admin/logs/getLogsForIssue')
.then(response => response.json())
.then(data => {
const issueText = data.logs;
const issueTextInput = document.getElementById('issue-text');
// before creating the new editor, remove the old one
while (issueTextInput.firstChild) {
issueTextInput.removeChild(issueTextInput.firstChild);
}
const issueEditor = monaco.editor.create(issueTextInput, {
value: issueText.join(''),
language: 'markdown',
minimap: { enabled: false },
automaticLayout: true
});
modal.show();
})
.catch(error => console.error('Error fetching issue logs:', error));
}
</script>
</div>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends "admin.html" %}
{% block admin_content %}
<h2>Settings</h2>
<form action="/admin/settings/save" method="post">
<div class="mb-3">
<h3>Default Playlist Users</h3>
<div id="defaultPlaylistUsers">
{% for user in jellyfin_users %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="default_playlist_users" value="{{ user.Id }}" id="user-{{ user.Id }}"
{% if user.Id in config['runtime_settings']['default_playlist_users'] %}checked{% endif %}>
<label class="form-check-label" for="user-{{ user.Id }}">{{ user.Name }}</label>
</div>
{% endfor %}
</div>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
{% endblock %}

View File

@@ -1,21 +1,40 @@
{% extends "admin.html" %}
{% block admin_content %}
<div class="container mt-5">
<!-- Tabelle für den Task-Status -->
<table class="table">
<div class="">
<table class="table ">
<thead>
<tr>
<th>Locked</th>
<th>Task Name</th>
<th>Status</th>
<th>Progress</th>
<th>Action</th>
</tr>
</thead>
<!-- Das Partial wird dynamisch über HTMX geladen -->
<tbody id="task-status" hx-get="/task_status" hx-trigger="every 1s" hx-swap="innerHTML">
{% include 'partials/_task_status.html' %}
</tbody>
</table>
</div>
<hr>
<h4>Unlock blocked tasks</h4>
<div>
<form hx-post="/unlock_key" hx-swap="outerHTML" hx-target="#empty">
<div class="mb-3">
<label for="inputLockKey" class="form-label">Lock Key Name</label>
<select class="form-select form-select-lg mb-3" aria-label="Select task lock to unlock" id="inputLockKey" name="inputLockKey">
{% for value in lock_keys %}
<option value="{{value}}">{{value}}</option>
{% endfor %}
</select>
<div id="inputLockKeyHelp" class="form-text">Provide a key name to to reset a lock. </div>
</div>
<button type="submit" class="btn btn-primary">Unlock</button>
</form>
</div>
<div id="empty"></div>
{% endblock %}

View File

@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<title>Jellyplist {{ title }}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
@@ -17,7 +17,8 @@
<script src="https://unpkg.com/htmx.org"></script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</head>
<body>
@@ -34,21 +35,30 @@
<nav>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link" href="/"><i class="fab fa-house"></i> Home</a>
</li>
{% for provider in registered_providers %}
<li class="nav-item">
<a class="nav-link " href="/browse?provider={{provider}}">
<i class="fab fa-{{provider.lower()}}"></i> Browse {{provider}}
</a>
</li>
{% endfor %}
<!-- <li class="nav-item">
<a class="nav-link" href="/playlists"><i class="fab fa-spotify"></i> Featured</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/categories"><i class="fa-solid fa-layer-group"></i>
Categories</a>
</li>
</li> -->
<li class="nav-item">
<a class="nav-link" href="/playlists/monitored"><i
class="fa-solid fa-tower-observation"></i> Monitored</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/jellyfin_playlists"><i class="fas fa-list"></i> Jellyfin
Playlists</a>
<a class="nav-link" href="/jellyfin_playlists"><i class="fas fa-list"></i> My Playlists</a>
</li>
{% if session.get('is_admin') and session.get('debug') %}
{% if session.get('is_admin') %}
<li class="nav-item">
<a class="nav-link" href="/admin"><i class="fas fa-flask"></i> Admin</a>
</li>
@@ -69,19 +79,28 @@
</div>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link" href="/"><i class="fas fa-house"></i> Home</a>
</li>
{% for provider in registered_providers %}
<li class="nav-item">
<a class="nav-link " href="/browse?provider={{provider}}">
<i class="fab fa-{{provider.lower()}}"></i> Browse {{provider}}
</a>
</li>
{% endfor %}
<!-- <li class="nav-item">
<a class="nav-link text-white" href="/playlists"><i class="fab fa-spotify"></i> Featured</a>
</li>
<li class="nav-item">
<a class="nav-link text-white" href="/categories"><i class="fa-solid fa-layer-group"></i>
Categories</a>
</li>
</li> -->
<li class="nav-item">
<a class="nav-link text-white" href="/playlists/monitored"><i
class="fa-solid fa-tower-observation"></i> Monitored </a>
</li>
<li class="nav-item">
<a class="nav-link text-white" href="/jellyfin_playlists"><i class="fas fa-list"></i> Jellyfin
Playlists</a>
<a class="nav-link text-white" href="/jellyfin_playlists"><i class="fas fa-list"></i> My Playlists</a>
</li>
{% if session.get('is_admin') %}
<li class="nav-item">
@@ -96,7 +115,7 @@
</li>
</ul>
</nav>
<span class="fixed-bottom m-3">{{version}}</span>
<span class="fixed-bottom m-3 ms-5">{{version | version_check}}</span>
</div>
<!-- Main content with toggle button for mobile sidebar -->
@@ -108,36 +127,29 @@
<i class="fas fa-bars"></i>
</button>
<h1 class="mb-4 ms-3">{{ title }}</h1>
<div class="d-flex align-items-center ">
<form action="/search" method="GET" class="w-100">
<div class="input-group">
<input
type="search"
class="form-control"
name="query"
placeholder="Search Spotify..."
aria-label="Search"
>
<button class="btn btn-primary" type="submit">Search</button>
</div>
</form>
<div class="ms-4">
<!-- Display Initials Badge -->
<span >{{ session.get('jellyfin_user_name') }}</span>
</div>
</div>
<!-- Search Form -->
<form action="/search" method="GET" class="d-flex flex-grow-1 mb-1 me-2">
<input
type="search"
class="form-control me-2"
name="query"
placeholder="Search ..."
aria-label="Search"
>
<button class="btn btn-primary" type="submit">Search</button>
</form>
<!-- Display Initials Badge -->
<span>{{ session.get('jellyfin_user_name') }}</span>
</div>
<h1 class="mb-1 ">{{ title }}</h1>
<h3 class="mb-4 ">{{ subtitle }}</h3>
{% block content %}{% endblock %}
</div>
</div>
<div id="alerts"></div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener("showToastMessages", function () {
console.log("showToastMessages")

61
templates/browse.html Normal file
View File

@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block content %}
{% for section in browse_data %}
<div class="browse-section">
<h1>{{ section.title }}</h1>
<div class="row row-cols-1 row-cols-md-4 row-cols-lg-6 g-4">
{% for card in section.items %}
<div class="col">
<div class="card shadow h-100 d-flex flex-column position-relative" >
<a href="/browse/page/{{ card.uri }}?provider={{provider_id}}">
<img src="{{ card.artwork.0.url }}" class="card-img-top" alt="{{ card.title }}">
<div class="card-body d-flex flex-column justify-content-between">
<h5 class="card-title">{{ card.title }}</h5>
</div>
</a>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% endblock %}
<style>
.browse-section {
margin: 20px 0;
}
.browse-section h1 {
font-size: 24px;
margin-bottom: 20px;
}
.browse-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 20px;
}
.browse-card {
position: relative;
border-radius: 8px;
overflow: hidden;
text-align: center;
color: #fff;
}
.browse-card img {
width: 100%;
height: auto;
display: block;
}
.browse-card .title {
position: absolute;
bottom: 10px;
left: 10px;
font-size: 18px;
text-shadow: 1px 1px 2px #000;
}
</style>

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<!-- <h1 class="mb-4">{{ data.title }}</h1>
<h6 class="mb-4">{{ data.subtitle }}</h6> -->
<div class="row row-cols-1 row-cols-md-4 row-cols-lg-6 g-4 mt-4" id="items-container">
{% for item in data %}
{% include 'partials/playlist_item.html' %}
{% endfor %}
</div>
{% endblock %}

View File

@@ -2,5 +2,36 @@
{% block content %}
<div class="container h-100">
<div class="d-flex justify-content-center h-100">
<div class="searchbar">
<!-- Form element with HTMX attributes -->
<form method="GET" action="/playlist/open" >
<input
class="search_input"
type="text"
name="playlist"
placeholder="Paste a Playlist ID or a complete link to a playlist"
required
>
<button type="submit" class="search_icon">
<i class="fas fa-search"></i>
</button>
</form>
{% if error_message %}
<div class="alert alert-danger mt-5" role="alert">
<h4 class="alert-heading">🚨Cant fetch playlist🚨</h4>
<p>{{ error_message }}</p>
<hr>
<p>Additional Information:</p>
<p>{{error_data}}</p>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,6 +1,16 @@
{% extends "base.html" %}
{% block content %}
{% if error_message %}
<div class="alert alert-danger mt-5" role="alert">
<h4 class="alert-heading">🚨Something went wrong🚨</h4>
<p>{{ error_message }}</p>
<hr>
<p>Additional Information:</p>
<p>{{error_data}}</p>
</div>
{% else %}
<h1 class="mb-4">{{ items_title }}</h1>
<h6 class="mb-4">{{ items_subtitle }}</h6>
<div class="row row-cols-1 row-cols-md-4 row-cols-lg-6 g-4" id="items-container">
@@ -8,5 +18,6 @@
</div>
{% endif %}
{% endblock %}

View File

@@ -1,6 +1,16 @@
{% extends "base.html" %}
{% block content %}
{% if error_message %}
<div class="alert alert-danger mt-5" role="alert">
<h4 class="alert-heading">🚨Something went wrong🚨</h4>
<p>{{ error_message }}</p>
<hr>
<p>Additional Information:</p>
<p>{{error_data}}</p>
</div>
{% else %}
<h1 >Your subscribed Jellyfin Playlists</h1>
<h6 ></h6>
<div class="row row-cols-1 row-cols-md-4 row-cols-lg-6 g-4" id="items-container">
@@ -10,6 +20,6 @@
{% endfor %}
</div>
{%endif%}
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block content %}
<div class="container-fluid">
{% for provider_id, playlists in provider_playlists_data.items() %}
<div class="provider-section mb-5">
<h2>{{ provider_id }}</h2>
<div class="row row-cols-2 row-cols-md-6 g-4">
{% for item in playlists %}
{% include 'partials/playlist_item.html' %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -1,12 +1,204 @@
{% if item.can_add %}
<button class="btn btn-success" hx-post="/addplaylist" hx-include="this" hx-swap="outerHTML" hx-target="this"
{% if session['is_admin'] %}
<button class="btn btn-primary" id="add-playlist-admin-{{item['id']}}" data-bs-toggle="tooltip" title="Add Playlist for Users">
<i class="fa-solid fa-users"> </i>
</button>
<div class="modal fade" id="addPlaylistModal-{{item['id']}}" tabindex="-1" aria-labelledby="addPlaylistModal-{{item['id']}}Label" aria-hidden="true" data-bs-backdrop="false" >
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addPlaylistModal-{{item['id']}}Label">Select Additional Users</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="allUsers-{{item['id']}}">
<!-- All users will be dynamically loaded here with checkboxes -->
</div>
<div class="d-flex justify-content-between mt-3">
<button class="btn btn-secondary" id="selectAllUsers-{{item['id']}}">Select All</button>
<button class="btn btn-success" id="addPlaylistButton-{{item['id']}}">Add Playlist</button>
</div>
</div>
</div>
</div>
</div>
<script>
document.getElementById("add-playlist-admin-{{item['id']}}").addEventListener('click', function() {
var modal = new bootstrap.Modal(document.getElementById("addPlaylistModal-{{item['id']}}"));
modal.show();
loadAllUsers{{item['id']}}();
});
function loadAllUsers{{item['id']}}() {
fetch("/admin/getJellyfinUsers")
.then(response => response.json())
.then(data => {
const allUsersDiv = document.getElementById("allUsers-{{item['id']}}");
allUsersDiv.innerHTML = '';
data.users.forEach(user => {
const checkbox = document.createElement('div');
checkbox.classList.add('form-check');
const isChecked = {{ config['runtime_settings']['default_playlist_users']|safe }}.includes(user.Id) ? 'checked' : '';
checkbox.innerHTML = `<input class="form-check-input" type="checkbox" value="${user.Id}" id="user-${user.Id}" ${isChecked}>
<label class="form-check-label" for="user-${user.Id}">${user.Name}</label>`;
allUsersDiv.appendChild(checkbox);
});
});
}
document.getElementById("selectAllUsers-{{item['id']}}").addEventListener('click', function() {
document.querySelectorAll("#allUsers-{{item['id']}} .form-check-input").forEach(checkbox => {
checkbox.checked = true;
});
});
document.getElementById("addPlaylistButton-{{item['id']}}").addEventListener('click', function() {
const selectedUsers = Array.from(document.querySelectorAll("#allUsers-{{item['id']}} .form-check-input:checked")).map(checkbox => checkbox.value);
const hxVals = {
item_id: "{{ item.id }}",
item_name: "{{ item.name }}",
additional_users: selectedUsers
};
fetch("/addplaylist?provider={{provider_id}}", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'HX-Request': 'true'
},
body: JSON.stringify(hxVals)
}).then(response => {
if (response.ok) {
location.reload();
}
});
});
</script>
{%else%}
<button class="btn btn-success" hx-post="/addplaylist?provider={{provider_id}}" hx-include="this" hx-swap="outerHTML" hx-target="this"
data-bs-toggle="tooltip" title="Add to my Jellyfin"
hx-vals='{"item_id": "{{ item.id }}", "item_name": "{{ item.name }}"}'>
<i class="fa-solid fa-circle-plus"> </i>
</button>
{% endif %}
{% elif item.can_remove %}
<button class="btn btn-warning" hx-delete="{{ url_for('delete_playlist', playlist_id=item['jellyfin_id']) }}"
<span id="item-can-remove-{{ item.id }}" >
<button class="btn btn-warning" hx-delete="{{ url_for('delete_playlist', playlist_id=item['jellyfin_id']) }}"
hx-include="this" hx-swap="outerHTML" hx-target="this" data-bs-toggle="tooltip" title="Remove from Jellyfin">
<i class="fa-solid fa-circle-minus"> </i>
</button>
{% endif %}
{% if session['is_admin'] and item.can_remove %}
<button class="btn btn-danger" id="confirm-delete-{{item['jellyfin_id']}}" data-bs-toggle="tooltip" title="Delete playlist from monitoring and remove (DELETE FOR ALL USERS) from Jellyfin">
<i class="fa-solid fa-trash"> </i>
</button>
<script>
document.getElementById("confirm-delete-{{item['jellyfin_id']}}").addEventListener('click', function() {
const button = this;
const icon = button.querySelector('i');
if (icon.classList.contains('fa-trash')) {
icon.classList.remove('fa-trash');
icon.classList.add('fa-check');
button.setAttribute('title', 'Click again to confirm deletion');
} else {
fetch("{{ url_for('wipe_playlist', playlist_id=item['jellyfin_id']) }}", {
method: 'DELETE',
headers: {
'HX-Request': 'true'
}
}).then(response => {
if (response.ok) {
button.closest('#item-can-remove-{{ item.id }}').outerHTML = '';
}
});
}
});
</script>
</span>
{% endif%}
{% if session['is_admin'] and item.can_remove %}
<button class="btn btn-info" id="manage-users-{{item['jellyfin_id']}}" data-bs-toggle="tooltip" title="Manage Users">
<i class="fa-solid fa-user"> </i>
</button>
<div class="modal fade" id="manageUsersModal-{{item['jellyfin_id']}}" tabindex="-1" aria-labelledby="manageUsersModal-{{item['jellyfin_id']}}Label" aria-hidden="true" data-bs-modal-backdrop="false">
<div class="modal-dialog modal-sm modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="manageUsersModal-{{item['jellyfin_id']}}Label">Manage Users</h5>
</div>
<div class="modal-body">
<div id="assignedUsers-{{item['jellyfin_id']}}">
<!-- Assigned users will be dynamically loaded here -->
</div>
<div class="input-group mt-3">
<select class="form-select" id="availableUsers-{{item['jellyfin_id']}}">
<!-- Available users will be dynamically loaded here -->
</select>
<button class="btn btn-primary" id="addUserButton-{{item['jellyfin_id']}}">Add User to Playlist</button>
</div>
</div>
</div>
</div>
</div>
<script>
document.getElementById("manage-users-{{item['jellyfin_id']}}").addEventListener('click', function() {
var modal = new bootstrap.Modal(document.getElementById("manageUsersModal-{{item['jellyfin_id']}}"));
modal.show();
loadUsers{{item['jellyfin_id']}}();
});
function loadUsers{{item['jellyfin_id']}}() {
fetch("/admin/getJellyfinPlaylistUsers?playlist={{item['jellyfin_id']}}")
.then(response => response.json())
.then(data => {
console.log("jellyfin playlist id: {{item['jellyfin_id']}}");
const assignedUsersDiv = document.getElementById("assignedUsers-{{item['jellyfin_id']}}");
console.log(assignedUsersDiv);
assignedUsersDiv.innerHTML = '';
data.assigned_users.forEach(user => {
const badge = document.createElement('span');
badge.classList.add('badge', 'bg-primary', 'me-1', 'mb-1');
badge.innerHTML = `${user.Name} <button class="btn btn-danger btn-sm ms-1" onclick="removeUser{{item['jellyfin_id']}}('${user.UserId}')">×</button>`;
assignedUsersDiv.appendChild(badge);
});
const availableUsersSelect = document.getElementById("availableUsers-{{item['jellyfin_id']}}");
availableUsersSelect.innerHTML = '';
data.remaining_users.forEach(user => {
const option = document.createElement('option');
option.value = user.Id;
option.textContent = user.Name;
availableUsersSelect.appendChild(option);
});
});
}
function removeUser{{item['jellyfin_id']}}(userId) {
fetch(`/admin/removeJellyfinUserFromPlaylist?user=${userId}&playlist={{item['jellyfin_id']}}`)
.then(response => response.json())
.then(data => {
if (data.success) {
loadUsers{{item['jellyfin_id']}}();
}
});
}
document.getElementById("addUserButton-{{item['jellyfin_id']}}").addEventListener('click', function() {
const userId = document.getElementById("availableUsers-{{item['jellyfin_id']}}").value;
fetch(`/admin/addJellyfinUserToPlaylist?user=${userId}&playlist={{item['jellyfin_id']}}`)
.then(response => response.json())
.then(data => {
if (data.success) {
loadUsers{{item['jellyfin_id']}}();
}
});
});
</script>
{% endif %}

View File

@@ -7,6 +7,10 @@
<th>Name</th>
<th>Artist(s)</th>
<th>Path</th>
<th>Container</th>
{% if config['DISPLAY_EXTENDED_AUDIO_DATA'] %}
<th></th>
{% endif %}
<th></th>
<th></th>
</tr>
@@ -14,17 +18,21 @@
<tbody>
{% for track in results %}
<tr>
<td>{{ track.Name }}</td>
<td>{{ track.Name | highlight(search_query) }}</td>
<td>{{ ', '.join(track.Artists) }}</td>
<td>{{ track.Path}}</td>
<td>{{ track.Container }}</td>
{% if config['DISPLAY_EXTENDED_AUDIO_DATA'] %}
<td> {{track.Path | audioprofile(track.Path) }}</td>
{% endif %}
<td>
<button class="btn btn-sm btn-primary" onclick="playJellyfinTrack(this, '{{ track.Id }}')">
<i class="fas fa-play"></i>
</button>
</td>
<td>
<button hx-swap="beforebegin" class="btn btn-sm btn-success" hx-post="/associate_track" hx-vals='{"jellyfin_id": "{{ track.Id }}","spotify_id": "{{ spotify_id}}"}'>
Associate Track
<button hx-swap="beforebegin" class="btn btn-sm btn-success" hx-post="/associate_track" hx-vals='{"jellyfin_id": "{{ track.Id }}","provider_track_id": "{{ provider_track_id}}"}' data-bs-toggle="tooltip" title="Link this Track [{{track.Id}}] with Provider-Track-ID {{ provider_track_id }}">
Link Track
</button>
</td>
</tr>

View File

@@ -1,14 +1,38 @@
<div class="d-flex align-items-center row sticky-top py-3 mb-3 bg-dark" style="top: 0; z-index: 1000;">
<div class="col-6">
<img src="{{ playlist_cover }}" class="img-fluid">
<img src="{{ item.image }}" class="img-fluid">
</div>
<div class="col-6">
<div class="playlist-info">
<h1>{{ playlist_name }}</h1>
<p>{{ playlist_description }}</p>
<p>{{ track_count }} songs, {{ total_duration }}</p>
<p>Last Updated: {{ last_updated}} | Last Change: {{ last_changed}}</p>
<h1>{{ item.name }}</h1>
<p>{{ item.description }}</p>
<p>{{ item.track_count }} songs, {{ total_duration }}</p>
<p>Last Updated: {{ item.last_updated | human_datetime}} | Last Change: {{ item.last_changed | human_datetime}}</p>
{% include 'partials/_add_remove_button.html' %}
<p>
{{item.jellyfin_id | jellyfin_link_button}}
{% if session['is_admin'] and item.jellyfin_id %}
<button id="refresh-playlist-btn" class="btn btn-primary mt-2">Refresh Playlist in Jellyfin</button>
<script>
document.getElementById('refresh-playlist-btn').addEventListener('click', function() {
fetch(`/refresh_playlist/{{item.jellyfin_id}}`)
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Playlist refreshed successfully');
} else {
alert('Failed to refresh playlist');
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while refreshing the playlist');
});
});
</script>
{% endif %}
</p>
</div>
</div>
</div>

View File

@@ -1,24 +0,0 @@
{% for item in items %}
{% include 'partials/_spotify_item.html' %}
{% endfor %}
{% if next_offset < total_items %}
<div hx-get="{{ endpoint }}?offset={{ next_offset }}{{ additional_query }}"
hx-trigger="revealed"
hx-swap="beforeend"
hx-indicator=".loading-indicator"
hx-target="#items-container"
class="loading-indicator text-center">
Loading more items...
</div>
{% endif %}
<script>
// Show the loading indicator only when it is active
document.querySelectorAll('.loading-indicator').forEach(indicator => {
indicator.addEventListener('htmx:afterRequest', () => {
indicator.style.display = 'none'; // Hide the indicator after the request completes
});
});
</script>

View File

@@ -1,22 +1,40 @@
{% for task_name, task in tasks.items() %}
<tr id="task-row-{{ task_name }}">
<td>{{ task_name }}</td>
<td>{{ task.state }}</td>
<td class="w-auto">
{% if task.lock_status %}
<i class="fas fa-lock text-warning"></i>
{% else %}
<i class="fas fa-unlock text-success"></i>
{% endif %}
</td>
<td class="w-25">{{ task_name }}</td>
<td class="w-50">{{ task.state }}</td>
<td>
{% if task.info.percent %}
{{ task.info.percent }}%
<div class="progress" style="height: 20px;">
<div
class="progress-bar {% if task.info.percent|round(0) == 100 %}bg-success{% else %}bg-primary{% endif %}"
role="progressbar"
style="width: {{ task.info.percent|round(2) }}%;"
aria-valuenow="{{ task.info.percent|round(2) }}"
aria-valuemin="0"
aria-valuemax="100">
{{ task.info.percent|round(2) }}%
</div>
</div>
{% else %}
N/A
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>
<div>
<button hx-post="/run_task/{{ task_name }}" hx-target="#task-row-{{ task_name }}" hx-swap="outerHTML"
class="btn btn-primary">
<button hx-post="/run_task/{{ task_name }}"
hx-target="#task-row-{{ task_name }}"
hx-swap="outerHTML"
class="btn btn-primary">
Run Task
</button>
</div>
</td>
</tr>
{% endfor %}
{% endfor %}

View File

@@ -1,18 +0,0 @@
<div class="toast align-items-center text-white {{ 'bg-success' if success else 'bg-danger' }} border-0" role="alert"
aria-live="assertive" aria-atomic="true" style="position: fixed; bottom: 20px; right: 20px; z-index: 1000;">
<div class="d-flex">
<div class="toast-body">
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"
aria-label="Close"></button>
</div>
</div>
<script>
var toastElList = [].slice.call(document.querySelectorAll('.toast'))
var toastList = toastElList.map(function (toastEl) {
return new bootstrap.Toast(toastEl)
})
toastList.forEach(toast => toast.show());
</script>

View File

@@ -1,76 +1,88 @@
<table class="table">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Title</th>
<th scope="col">Artist</th>
<th scope="col">Duration</th>
<th scope="col">Spotify</th>
<th scope="col">Preview</th>
<th scope="col">Status</th>
<th scope="col">Jellyfin</th>
</tr>
</thead>
<tbody>
{% for track in tracks %}
<tr>
<th scope="row">{{ loop.index }}</th>
<td>{{ track.title }}</td>
<td>{{ track.artist }}</td>
<td>{{ track.duration }}</td>
<td>
<a href="{{ track.url }}" target="_blank" class="text-success" data-bs-toggle="tooltip" title="Open in Spotify">
<i class="fab fa-spotify fa-lg"></i>
</a>
</td>
<td>
{% if track.preview_url %}
<button class="btn btn-sm btn-primary" onclick="playPreview(this, '{{ track.preview_url }}')" data-bs-toggle="tooltip" title="Play Preview">
<i class="fas fa-play"></i>
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Title</th>
<th scope="col">Artist</th>
<th scope="col">Duration</th>
<th scope="col">{{provider_id}}</th>
<th scope="col">Status</th>
<th scope="col">Jellyfin</th>
</tr>
</thead>
<tbody>
{% for track in tracks %}
<tr hx-get="/track_details/{{track.provider_track_id}}?provider={{ provider_id }}"
hx-target="#trackDetailsModalcontent" hx-trigger="dblclick" hx-on="htmx:afterOnLoad:showModal">
<th scope="row">{{ loop.index }}</th>
<td>{{ track.title }}</td>
<td>{{ track.artist }}</td>
<td>{{ track.duration }}</td>
<td>
<a href="{{ track.url[0] }}" target="_blank" class="text-success" data-bs-toggle="tooltip"
title="Open in {{ track.provider_id }}">
<i class="fab fa-{{ track.provider_id.lower() }} fa-lg"></i>
</a>
</td>
<td>
{% if not track.downloaded %}
<button class="btn btn-sm btn-danger" data-bs-toggle="tooltip"
title="{{ track.download_status if track.download_status else 'Not downloaded'}}">
<i class="fa-solid fa-triangle-exclamation"></i>
</button>
{% else %}
<button class="btn btn-sm btn-success" data-bs-toggle="tooltip" title="Downloaded">
<i class="fa-solid fa-check"></i>
</button>
{% endif %}
</td>
<td>
{% set title = track.title | replace("'","") %}
{% if track.jellyfin_id %}
<button class="btn btn-sm btn-success"
onclick="handleJellyfinClick(event, '{{ track.jellyfin_id }}', '{{ title }}', '{{ track.provider_track_id }}')"
data-bs-toggle="tooltip" title="Play from Jellyfin (Hold CTRL Key to reassing a new track)">
<i class="fas fa-play"></i>
</button>
{% elif track.downloaded %}
<span data-bs-toggle="tooltip"
title="Track Downloaded, but not in Jellyfin or could not be associated automatically. You can try to do the association manually">
<button class="btn btn-sm btn-warning"
onclick="openSearchModal('{{ title }}','{{track.provider_track_id}}')">
<i class="fas fa-triangle-exclamation"></i>
</button>
{% else %}
<span data-bs-toggle="tooltip" title="No Preview Available">
<button class="btn btn-sm" disabled><i class="fas fa-ban"></i></button>
</span>
{% endif %}
</td>
<td>
{% if not track.downloaded %}
<button class="btn btn-sm btn-danger"
data-bs-toggle="tooltip" title="{{ track.download_status if track.download_status else 'Not downloaded'}}">
<i class="fa-solid fa-triangle-exclamation"></i>
</button>
{% else %}
<button class="btn btn-sm btn-success" data-bs-toggle="tooltip" title="Track downloaded">
<i class="fa-solid fa-circle-check"></i>
</button>
{% endif %}
</td>
<td>
{% if track.jellyfin_id %}
<button class="btn btn-sm btn-success" onclick="playJellyfinTrack(this, '{{ track.jellyfin_id }}')" data-bs-toggle="tooltip" title="Play from Jellyfin">
<i class="fas fa-play"></i>
</button>
{% elif track.downloaded %}
<span data-bs-toggle="tooltip" title="Track Downloaded, but not in Jellyfin or could not be associated automatically. You can try to do the association manually">
{% set title = track.title | replace("'","") %}
<button class="btn btn-sm btn-warning" onclick="openSearchModal('{{ title }}','{{track.spotify_id}}')">
<i class="fas fa-triangle-exclamation"></i>
</button>
</span>
{% else %}
<span data-bs-toggle="tooltip" title="Not Available">
<button class="btn btn-sm" disabled><i class="fas fa-ban"></i></button>
</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</span>
{% else %}
<span>
<button class="btn btn-sm" onclick="openSearchModal('{{ title }}','{{track.provider_track_id}}')"
data-bs-toggle="tooltip" title="Click to assign a track"><i class="fas fa-ban"></i></button>
</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="modal fade" id="trackDetailsModal" tabindex="-1" aria-labelledby="trackDetailsModalLabel"
aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content" id="trackDetailsModalcontent">
</div>
</div>
</div>
<script>
document.addEventListener('htmx:afterOnLoad', function(event) {
if (event.detail.target.id === 'trackDetailsModalcontent') {
const modal = new bootstrap.Modal(document.getElementById('trackDetailsModal'));
modal.show();
}
});
</script>
<div class="modal fade" id="searchModal" tabindex="-1" aria-labelledby="searchModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="searchModalLabel">Search Jellyfin for Track</h5>
@@ -80,15 +92,21 @@
<!-- htmx-enabled form -->
<form id="search-form" hx-get="/search_jellyfin" hx-target="#search-results" hx-trigger="submit">
<div class="input-group mb-3">
<input type="text" class="form-control" id="search-query" name="search_query" placeholder="Search for a track...">
<input type="hidden" class="form-control" id="spotify-id" name="spotify_id" >
<input type="text" class="form-control" id="search-query" name="search_query"
placeholder="Search for a track...">
<input type="hidden" class="form-control" id="provider-track-id" name="provider_track_id">
<button class="btn btn-primary" type="submit">Search</button>
</div>
</form>
<div id="search-results">
<!-- Search results will be inserted here by htmx -->
<div id="loading-spinner" class="d-flex justify-content-center my-3" style="display: none;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
<div class="col">
<div class="col" id="item-id-{{ item.id }}">
<div class="card shadow h-100 d-flex flex-column position-relative">
<!-- Badge: Only show if status is available (i.e., playlist has been requested) -->
@@ -20,7 +20,10 @@
<!-- Card Image -->
<div style="position: relative;">
<img src="{{ item.image }}" class="card-img-top" alt="{{ item.name }}">
<a href="/playlist/view/{{ item.id }}?provider={{provider_id}}">
<img src="{{ item.image }}" class="card-img-top" alt="{{ item.name }}">
</a>
</div>
<!-- Card Body -->
@@ -30,17 +33,10 @@
<p class="card-text">{{ item.description }}</p>
</div>
<div class="mt-auto pt-3">
{% if item.type == 'category'%}
<a href="{{ item.url }}" class="btn btn-primary" data-bs-toggle="tooltip" title="View Playlists">
<i class="fa-solid fa-eye"></i>
</a>
{%else%}
<a href="/playlist/view/{{ item.id }}" class="btn btn-primary" data-bs-toggle="tooltip"
<a href="/playlist/view/{{ item.id }}?provider={{provider_id}}" class="btn btn-primary" data-bs-toggle="tooltip"
title="View Playlist details">
<i class="fa-solid fa-eye"></i>
</a>
{%endif%}
{% include 'partials/_add_remove_button.html' %}
</div>
</div>

View File

@@ -0,0 +1,23 @@
<div class="modal-header">
<h5 class="modal-title" id="trackDetailsModalLabel">Track Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p><strong>Title:</strong> {{ track.title }}</p>
<p><strong>Artist:</strong> {{ track.artist }}</p>
<p><strong>Duration:</strong> {{ track.duration }}</p>
<p><strong>Duration (ms):</strong> {{ track.duration_ms }}</p>
<p><strong>Provider:</strong> {{ track.provider_id }}</p>
<p><strong>Provider Track URL:</strong> <a href="{{track.provider_track_url}}" target="_blank">{{track.provider_track_url}}</a></p>
<!-- <p><strong>Preview URL:</strong> <a href="{{ track.preview_url }}" target="_blank">{{ track.preview_url if track.preview_url else 'No Preview Available' }}</a></p> -->
<p><strong>Status:</strong> {{ 'Downloaded' if track.downloaded else 'Not Downloaded' }}</p>
<p><strong>Jellyfin ID:</strong> {{ track.jellyfin_id | jellyfin_link }}</p>
<p><strong>Provider Track ID:</strong> {{ track.provider_track_id }}</p>
<p><strong>Download Status:</strong> {{ track.download_status }}</p>
<p><strong>Filesystem Path:</strong> {{ track.filesystem_path }}</p>
<p><strong>Jellyfin Filesystem Path:</strong> {{ track.jellyfin_filesystem_path if track.jellyfin_filesystem_path else 'N/A' }}</p>
<p>{{ track.jellyfin_filesystem_path | audioprofile(track.jellyfin_filesystem_path) if track.jellyfin_filesystem_path else 'N/A' }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>

View File

@@ -1 +1 @@
__version__ = "0.1.2"
__version__ = "v0.1.10"