diff --git a/.github/workflows/publish-installers.yml b/.github/workflows/publish-installers.yml
index 0e0b1082..a9f6cda3 100644
--- a/.github/workflows/publish-installers.yml
+++ b/.github/workflows/publish-installers.yml
@@ -125,41 +125,21 @@ jobs:
echo "$EOF" >> $GITHUB_OUTPUT
- name: Create Release
- uses: actions/create-release@v1
+ uses: softprops/action-gh-release@v1
id: create_release
env:
GITHUB_TOKEN: ${{ secrets.GHACTIONS_TOKEN }}
with:
tag_name: ${{ steps.get_version.outputs.RELEASE_VERSION }}
- release_name: Tautulli ${{ steps.get_version.outputs.RELEASE_VERSION }}
+ name: Tautulli ${{ steps.get_version.outputs.RELEASE_VERSION }}
body: |
## Changelog
##${{ steps.get_changelog.outputs.CHANGELOG }}
- draft: false
prerelease: ${{ endsWith(steps.get_version.outputs.RELEASE_VERSION, '-beta') }}
-
- - name: Upload Windows Installer
- uses: actions/upload-release-asset@v1
- if: env.WORKFLOW_CONCLUSION == 'success'
- env:
- GITHUB_TOKEN: ${{ secrets.GHACTIONS_TOKEN }}
- with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
- asset_path: Tautulli-windows-installer/Tautulli-windows-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.exe
- asset_name: Tautulli-windows-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.exe
- asset_content_type: application/vnd.microsoft.portable-executable
-
- - name: Upload MacOS Installer
- uses: actions/upload-release-asset@v1
- if: env.WORKFLOW_CONCLUSION == 'success'
- env:
- GITHUB_TOKEN: ${{ secrets.GHACTIONS_TOKEN }}
- with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
- asset_path: Tautulli-macos-installer/Tautulli-macos-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.pkg
- asset_name: Tautulli-macos-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.pkg
- asset_content_type: application/vnd.apple.installer+xml
+ files: |
+ Tautulli-windows-installer/Tautulli-windows-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.exe
+ Tautulli-macos-installer/Tautulli-macos-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.pkg
discord:
name: Discord Notification
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5eb4c5a..26dc180c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,26 @@
# Changelog
+## v2.13.4 (2023-12-07)
+
+* UI:
+ * Fix: Tautulli configuration settings page not loading when system language is None.
+ * Fix: Login cookie expiring too quickly.
+
+
+## v2.13.3 (2023-12-03)
+
+* Notifications:
+ * New: Added duration_time notification parameter.
+ * New: Added file_size_bytes notification parameter.
+ * New: Added time formats notification text modifiers.
+ * New: Added support for thetvdb_url for movies.
+* UI:
+ * Fix: Activity card overflowing due to screen scaling. (#2033)
+ * Fix: Stream duration on activity card not being updated on track changes in some cases. (#2206)
+
+
## v2.13.2 (2023-10-26)
+
* History:
* New: Added quarter values icons for history watch status. (#2179, #2156) (Thanks @herby2212)
* Graphs:
@@ -15,6 +35,7 @@
## v2.13.1 (2023-08-25)
+
* Notes:
* Support for Python 3.7 has been dropped. The minimum Python version is now 3.8.
* Other:
diff --git a/Tautulli.py b/Tautulli.py
index 5d7f60df..8616df81 100755
--- a/Tautulli.py
+++ b/Tautulli.py
@@ -24,10 +24,10 @@ import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'lib'))
-import appdirs
import argparse
import datetime
import locale
+import platformdirs
import pytz
import signal
import shutil
@@ -185,7 +185,7 @@ def main():
if args.datadir:
plexpy.DATA_DIR = args.datadir
elif plexpy.FROZEN:
- plexpy.DATA_DIR = appdirs.user_data_dir("Tautulli", False)
+ plexpy.DATA_DIR = platformdirs.user_data_dir("Tautulli", False)
else:
plexpy.DATA_DIR = plexpy.PROG_DIR
diff --git a/data/interfaces/default/configuration_table.html b/data/interfaces/default/configuration_table.html
index 66d8ef40..e0f3155d 100644
--- a/data/interfaces/default/configuration_table.html
+++ b/data/interfaces/default/configuration_table.html
@@ -74,7 +74,7 @@ DOCUMENTATION :: END
System Language: |
- ${plexpy.SYS_LANGUAGE + (' (override {})'.format(plexpy.CONFIG.PMS_LANGUAGE) if plexpy.CONFIG.PMS_LANGUAGE else '')} |
+ ${plexpy.SYS_LANGUAGE}${' (override {})'.format(plexpy.CONFIG.PMS_LANGUAGE) if plexpy.CONFIG.PMS_LANGUAGE else ''} |
Python Version: |
diff --git a/data/interfaces/default/css/tautulli.css b/data/interfaces/default/css/tautulli.css
index 0c81f0e5..f8b15890 100644
--- a/data/interfaces/default/css/tautulli.css
+++ b/data/interfaces/default/css/tautulli.css
@@ -965,7 +965,7 @@ a .users-poster-face:hover {
font-size: 10px;
text-align: right;
text-transform: uppercase;
- line-height: 14px;
+ line-height: 10px;
-webkit-flex-shrink: 0;
flex-shrink: 0;
}
diff --git a/data/interfaces/default/index.html b/data/interfaces/default/index.html
index 8d5f66d1..6e4818c7 100644
--- a/data/interfaces/default/index.html
+++ b/data/interfaces/default/index.html
@@ -562,6 +562,7 @@
// Update the stream progress times
$('#stream-eta-' + key).html(moment().add(parseInt(s.duration) - parseInt(s.view_offset), 'milliseconds').format(time_format));
+ $('#stream-duration-' + key).html(millisecondsToMinutes(parseInt(s.stream_duration), false));
var stream_view_offset = $('#stream-view-offset-' + key);
stream_view_offset.data('state', s.state);
if (stream_view_offset.data('last_view_offset') !== s.view_offset) {
diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html
index 6710ff79..883d4d3d 100644
--- a/data/interfaces/default/settings.html
+++ b/data/interfaces/default/settings.html
@@ -1923,6 +1923,21 @@
Example:
{media_type} --> movie
{media_type!c} --> Movie
+
+
+
Time Formats
+
+
+
+ Notification parameters which are "in date format" or "in time format" can be formatted using the
+ Date & Time Format Options
+ by adding a :format specifier.
+ If no format is specified, the default Date Format and Time Format under Settings > General will be used.
+
+
Example:
+
{started_datestamp:ddd, MMMM DD, YYYY} --> Mon, December 25, 2023
+{stopped_timestamp:h:mm a} --> 9:56 pm
+{duration_time:HH:mm:ss} --> 01:42:20
List Slicing
@@ -1994,7 +2009,7 @@ Rating: {rating}/10 --> Rating: /10
Evaluation
Parameter
Case Modifier
-
List Slicing
+
Time Formats / List Slicing
Suffix
Example:
diff --git a/lib/appdirs.py b/lib/appdirs.py
deleted file mode 100644
index 2acd1deb..00000000
--- a/lib/appdirs.py
+++ /dev/null
@@ -1,608 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-# Copyright (c) 2005-2010 ActiveState Software Inc.
-# Copyright (c) 2013 Eddy Petrișor
-
-"""Utilities for determining application-specific dirs.
-
-See
for details and usage.
-"""
-# Dev Notes:
-# - MSDN on where to store app data files:
-# http://support.microsoft.com/default.aspx?scid=kb;en-us;310294#XSLTH3194121123120121120120
-# - Mac OS X: http://developer.apple.com/documentation/MacOSX/Conceptual/BPFileSystem/index.html
-# - XDG spec for Un*x: http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
-
-__version__ = "1.4.4"
-__version_info__ = tuple(int(segment) for segment in __version__.split("."))
-
-
-import sys
-import os
-
-PY3 = sys.version_info[0] == 3
-
-if PY3:
- unicode = str
-
-if sys.platform.startswith('java'):
- import platform
- os_name = platform.java_ver()[3][0]
- if os_name.startswith('Windows'): # "Windows XP", "Windows 7", etc.
- system = 'win32'
- elif os_name.startswith('Mac'): # "Mac OS X", etc.
- system = 'darwin'
- else: # "Linux", "SunOS", "FreeBSD", etc.
- # Setting this to "linux2" is not ideal, but only Windows or Mac
- # are actually checked for and the rest of the module expects
- # *sys.platform* style strings.
- system = 'linux2'
-else:
- system = sys.platform
-
-
-
-def user_data_dir(appname=None, appauthor=None, version=None, roaming=False):
- r"""Return full path to the user-specific data dir for this application.
-
- "appname" is the name of application.
- If None, just the system directory is returned.
- "appauthor" (only used on Windows) is the name of the
- appauthor or distributing body for this application. Typically
- it is the owning company name. This falls back to appname. You may
- pass False to disable it.
- "version" is an optional version path element to append to the
- path. You might want to use this if you want multiple versions
- of your app to be able to run independently. If used, this
- would typically be ".".
- Only applied when appname is present.
- "roaming" (boolean, default False) can be set True to use the Windows
- roaming appdata directory. That means that for users on a Windows
- network setup for roaming profiles, this user data will be
- sync'd on login. See
-
- for a discussion of issues.
-
- Typical user data directories are:
- Mac OS X: ~/Library/Application Support/
- Unix: ~/.local/share/ # or in $XDG_DATA_HOME, if defined
- Win XP (not roaming): C:\Documents and Settings\\Application Data\\
- Win XP (roaming): C:\Documents and Settings\\Local Settings\Application Data\\
- Win 7 (not roaming): C:\Users\\AppData\Local\\
- Win 7 (roaming): C:\Users\\AppData\Roaming\\
-
- For Unix, we follow the XDG spec and support $XDG_DATA_HOME.
- That means, by default "~/.local/share/".
- """
- if system == "win32":
- if appauthor is None:
- appauthor = appname
- const = roaming and "CSIDL_APPDATA" or "CSIDL_LOCAL_APPDATA"
- path = os.path.normpath(_get_win_folder(const))
- if appname:
- if appauthor is not False:
- path = os.path.join(path, appauthor, appname)
- else:
- path = os.path.join(path, appname)
- elif system == 'darwin':
- path = os.path.expanduser('~/Library/Application Support/')
- if appname:
- path = os.path.join(path, appname)
- else:
- path = os.getenv('XDG_DATA_HOME', os.path.expanduser("~/.local/share"))
- if appname:
- path = os.path.join(path, appname)
- if appname and version:
- path = os.path.join(path, version)
- return path
-
-
-def site_data_dir(appname=None, appauthor=None, version=None, multipath=False):
- r"""Return full path to the user-shared data dir for this application.
-
- "appname" is the name of application.
- If None, just the system directory is returned.
- "appauthor" (only used on Windows) is the name of the
- appauthor or distributing body for this application. Typically
- it is the owning company name. This falls back to appname. You may
- pass False to disable it.
- "version" is an optional version path element to append to the
- path. You might want to use this if you want multiple versions
- of your app to be able to run independently. If used, this
- would typically be ".".
- Only applied when appname is present.
- "multipath" is an optional parameter only applicable to *nix
- which indicates that the entire list of data dirs should be
- returned. By default, the first item from XDG_DATA_DIRS is
- returned, or '/usr/local/share/',
- if XDG_DATA_DIRS is not set
-
- Typical site data directories are:
- Mac OS X: /Library/Application Support/
- Unix: /usr/local/share/ or /usr/share/
- Win XP: C:\Documents and Settings\All Users\Application Data\\
- Vista: (Fail! "C:\ProgramData" is a hidden *system* directory on Vista.)
- Win 7: C:\ProgramData\\ # Hidden, but writeable on Win 7.
-
- For Unix, this is using the $XDG_DATA_DIRS[0] default.
-
- WARNING: Do not use this on Windows. See the Vista-Fail note above for why.
- """
- if system == "win32":
- if appauthor is None:
- appauthor = appname
- path = os.path.normpath(_get_win_folder("CSIDL_COMMON_APPDATA"))
- if appname:
- if appauthor is not False:
- path = os.path.join(path, appauthor, appname)
- else:
- path = os.path.join(path, appname)
- elif system == 'darwin':
- path = os.path.expanduser('/Library/Application Support')
- if appname:
- path = os.path.join(path, appname)
- else:
- # XDG default for $XDG_DATA_DIRS
- # only first, if multipath is False
- path = os.getenv('XDG_DATA_DIRS',
- os.pathsep.join(['/usr/local/share', '/usr/share']))
- pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)]
- if appname:
- if version:
- appname = os.path.join(appname, version)
- pathlist = [os.sep.join([x, appname]) for x in pathlist]
-
- if multipath:
- path = os.pathsep.join(pathlist)
- else:
- path = pathlist[0]
- return path
-
- if appname and version:
- path = os.path.join(path, version)
- return path
-
-
-def user_config_dir(appname=None, appauthor=None, version=None, roaming=False):
- r"""Return full path to the user-specific config dir for this application.
-
- "appname" is the name of application.
- If None, just the system directory is returned.
- "appauthor" (only used on Windows) is the name of the
- appauthor or distributing body for this application. Typically
- it is the owning company name. This falls back to appname. You may
- pass False to disable it.
- "version" is an optional version path element to append to the
- path. You might want to use this if you want multiple versions
- of your app to be able to run independently. If used, this
- would typically be ".".
- Only applied when appname is present.
- "roaming" (boolean, default False) can be set True to use the Windows
- roaming appdata directory. That means that for users on a Windows
- network setup for roaming profiles, this user data will be
- sync'd on login. See
-
- for a discussion of issues.
-
- Typical user config directories are:
- Mac OS X: same as user_data_dir
- Unix: ~/.config/ # or in $XDG_CONFIG_HOME, if defined
- Win *: same as user_data_dir
-
- For Unix, we follow the XDG spec and support $XDG_CONFIG_HOME.
- That means, by default "~/.config/".
- """
- if system in ["win32", "darwin"]:
- path = user_data_dir(appname, appauthor, None, roaming)
- else:
- path = os.getenv('XDG_CONFIG_HOME', os.path.expanduser("~/.config"))
- if appname:
- path = os.path.join(path, appname)
- if appname and version:
- path = os.path.join(path, version)
- return path
-
-
-def site_config_dir(appname=None, appauthor=None, version=None, multipath=False):
- r"""Return full path to the user-shared data dir for this application.
-
- "appname" is the name of application.
- If None, just the system directory is returned.
- "appauthor" (only used on Windows) is the name of the
- appauthor or distributing body for this application. Typically
- it is the owning company name. This falls back to appname. You may
- pass False to disable it.
- "version" is an optional version path element to append to the
- path. You might want to use this if you want multiple versions
- of your app to be able to run independently. If used, this
- would typically be ".".
- Only applied when appname is present.
- "multipath" is an optional parameter only applicable to *nix
- which indicates that the entire list of config dirs should be
- returned. By default, the first item from XDG_CONFIG_DIRS is
- returned, or '/etc/xdg/', if XDG_CONFIG_DIRS is not set
-
- Typical site config directories are:
- Mac OS X: same as site_data_dir
- Unix: /etc/xdg/ or $XDG_CONFIG_DIRS[i]/ for each value in
- $XDG_CONFIG_DIRS
- Win *: same as site_data_dir
- Vista: (Fail! "C:\ProgramData" is a hidden *system* directory on Vista.)
-
- For Unix, this is using the $XDG_CONFIG_DIRS[0] default, if multipath=False
-
- WARNING: Do not use this on Windows. See the Vista-Fail note above for why.
- """
- if system in ["win32", "darwin"]:
- path = site_data_dir(appname, appauthor)
- if appname and version:
- path = os.path.join(path, version)
- else:
- # XDG default for $XDG_CONFIG_DIRS
- # only first, if multipath is False
- path = os.getenv('XDG_CONFIG_DIRS', '/etc/xdg')
- pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)]
- if appname:
- if version:
- appname = os.path.join(appname, version)
- pathlist = [os.sep.join([x, appname]) for x in pathlist]
-
- if multipath:
- path = os.pathsep.join(pathlist)
- else:
- path = pathlist[0]
- return path
-
-
-def user_cache_dir(appname=None, appauthor=None, version=None, opinion=True):
- r"""Return full path to the user-specific cache dir for this application.
-
- "appname" is the name of application.
- If None, just the system directory is returned.
- "appauthor" (only used on Windows) is the name of the
- appauthor or distributing body for this application. Typically
- it is the owning company name. This falls back to appname. You may
- pass False to disable it.
- "version" is an optional version path element to append to the
- path. You might want to use this if you want multiple versions
- of your app to be able to run independently. If used, this
- would typically be ".".
- Only applied when appname is present.
- "opinion" (boolean) can be False to disable the appending of
- "Cache" to the base app data dir for Windows. See
- discussion below.
-
- Typical user cache directories are:
- Mac OS X: ~/Library/Caches/
- Unix: ~/.cache/ (XDG default)
- Win XP: C:\Documents and Settings\\Local Settings\Application Data\\\Cache
- Vista: C:\Users\\AppData\Local\\\Cache
-
- On Windows the only suggestion in the MSDN docs is that local settings go in
- the `CSIDL_LOCAL_APPDATA` directory. This is identical to the non-roaming
- app data dir (the default returned by `user_data_dir` above). Apps typically
- put cache data somewhere *under* the given dir here. Some examples:
- ...\Mozilla\Firefox\Profiles\\Cache
- ...\Acme\SuperApp\Cache\1.0
- OPINION: This function appends "Cache" to the `CSIDL_LOCAL_APPDATA` value.
- This can be disabled with the `opinion=False` option.
- """
- if system == "win32":
- if appauthor is None:
- appauthor = appname
- path = os.path.normpath(_get_win_folder("CSIDL_LOCAL_APPDATA"))
- if appname:
- if appauthor is not False:
- path = os.path.join(path, appauthor, appname)
- else:
- path = os.path.join(path, appname)
- if opinion:
- path = os.path.join(path, "Cache")
- elif system == 'darwin':
- path = os.path.expanduser('~/Library/Caches')
- if appname:
- path = os.path.join(path, appname)
- else:
- path = os.getenv('XDG_CACHE_HOME', os.path.expanduser('~/.cache'))
- if appname:
- path = os.path.join(path, appname)
- if appname and version:
- path = os.path.join(path, version)
- return path
-
-
-def user_state_dir(appname=None, appauthor=None, version=None, roaming=False):
- r"""Return full path to the user-specific state dir for this application.
-
- "appname" is the name of application.
- If None, just the system directory is returned.
- "appauthor" (only used on Windows) is the name of the
- appauthor or distributing body for this application. Typically
- it is the owning company name. This falls back to appname. You may
- pass False to disable it.
- "version" is an optional version path element to append to the
- path. You might want to use this if you want multiple versions
- of your app to be able to run independently. If used, this
- would typically be ".".
- Only applied when appname is present.
- "roaming" (boolean, default False) can be set True to use the Windows
- roaming appdata directory. That means that for users on a Windows
- network setup for roaming profiles, this user data will be
- sync'd on login. See
-
- for a discussion of issues.
-
- Typical user state directories are:
- Mac OS X: same as user_data_dir
- Unix: ~/.local/state/ # or in $XDG_STATE_HOME, if defined
- Win *: same as user_data_dir
-
- For Unix, we follow this Debian proposal
- to extend the XDG spec and support $XDG_STATE_HOME.
-
- That means, by default "~/.local/state/".
- """
- if system in ["win32", "darwin"]:
- path = user_data_dir(appname, appauthor, None, roaming)
- else:
- path = os.getenv('XDG_STATE_HOME', os.path.expanduser("~/.local/state"))
- if appname:
- path = os.path.join(path, appname)
- if appname and version:
- path = os.path.join(path, version)
- return path
-
-
-def user_log_dir(appname=None, appauthor=None, version=None, opinion=True):
- r"""Return full path to the user-specific log dir for this application.
-
- "appname" is the name of application.
- If None, just the system directory is returned.
- "appauthor" (only used on Windows) is the name of the
- appauthor or distributing body for this application. Typically
- it is the owning company name. This falls back to appname. You may
- pass False to disable it.
- "version" is an optional version path element to append to the
- path. You might want to use this if you want multiple versions
- of your app to be able to run independently. If used, this
- would typically be ".".
- Only applied when appname is present.
- "opinion" (boolean) can be False to disable the appending of
- "Logs" to the base app data dir for Windows, and "log" to the
- base cache dir for Unix. See discussion below.
-
- Typical user log directories are:
- Mac OS X: ~/Library/Logs/
- Unix: ~/.cache//log # or under $XDG_CACHE_HOME if defined
- Win XP: C:\Documents and Settings\\Local Settings\Application Data\\\Logs
- Vista: C:\Users\\AppData\Local\\\Logs
-
- On Windows the only suggestion in the MSDN docs is that local settings
- go in the `CSIDL_LOCAL_APPDATA` directory. (Note: I'm interested in
- examples of what some windows apps use for a logs dir.)
-
- OPINION: This function appends "Logs" to the `CSIDL_LOCAL_APPDATA`
- value for Windows and appends "log" to the user cache dir for Unix.
- This can be disabled with the `opinion=False` option.
- """
- if system == "darwin":
- path = os.path.join(
- os.path.expanduser('~/Library/Logs'),
- appname)
- elif system == "win32":
- path = user_data_dir(appname, appauthor, version)
- version = False
- if opinion:
- path = os.path.join(path, "Logs")
- else:
- path = user_cache_dir(appname, appauthor, version)
- version = False
- if opinion:
- path = os.path.join(path, "log")
- if appname and version:
- path = os.path.join(path, version)
- return path
-
-
-class AppDirs(object):
- """Convenience wrapper for getting application dirs."""
- def __init__(self, appname=None, appauthor=None, version=None,
- roaming=False, multipath=False):
- self.appname = appname
- self.appauthor = appauthor
- self.version = version
- self.roaming = roaming
- self.multipath = multipath
-
- @property
- def user_data_dir(self):
- return user_data_dir(self.appname, self.appauthor,
- version=self.version, roaming=self.roaming)
-
- @property
- def site_data_dir(self):
- return site_data_dir(self.appname, self.appauthor,
- version=self.version, multipath=self.multipath)
-
- @property
- def user_config_dir(self):
- return user_config_dir(self.appname, self.appauthor,
- version=self.version, roaming=self.roaming)
-
- @property
- def site_config_dir(self):
- return site_config_dir(self.appname, self.appauthor,
- version=self.version, multipath=self.multipath)
-
- @property
- def user_cache_dir(self):
- return user_cache_dir(self.appname, self.appauthor,
- version=self.version)
-
- @property
- def user_state_dir(self):
- return user_state_dir(self.appname, self.appauthor,
- version=self.version)
-
- @property
- def user_log_dir(self):
- return user_log_dir(self.appname, self.appauthor,
- version=self.version)
-
-
-#---- internal support stuff
-
-def _get_win_folder_from_registry(csidl_name):
- """This is a fallback technique at best. I'm not sure if using the
- registry for this guarantees us the correct answer for all CSIDL_*
- names.
- """
- if PY3:
- import winreg as _winreg
- else:
- import _winreg
-
- shell_folder_name = {
- "CSIDL_APPDATA": "AppData",
- "CSIDL_COMMON_APPDATA": "Common AppData",
- "CSIDL_LOCAL_APPDATA": "Local AppData",
- }[csidl_name]
-
- key = _winreg.OpenKey(
- _winreg.HKEY_CURRENT_USER,
- r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders"
- )
- dir, type = _winreg.QueryValueEx(key, shell_folder_name)
- return dir
-
-
-def _get_win_folder_with_pywin32(csidl_name):
- from win32com.shell import shellcon, shell
- dir = shell.SHGetFolderPath(0, getattr(shellcon, csidl_name), 0, 0)
- # Try to make this a unicode path because SHGetFolderPath does
- # not return unicode strings when there is unicode data in the
- # path.
- try:
- dir = unicode(dir)
-
- # Downgrade to short path name if have highbit chars. See
- # .
- has_high_char = False
- for c in dir:
- if ord(c) > 255:
- has_high_char = True
- break
- if has_high_char:
- try:
- import win32api
- dir = win32api.GetShortPathName(dir)
- except ImportError:
- pass
- except UnicodeError:
- pass
- return dir
-
-
-def _get_win_folder_with_ctypes(csidl_name):
- import ctypes
-
- csidl_const = {
- "CSIDL_APPDATA": 26,
- "CSIDL_COMMON_APPDATA": 35,
- "CSIDL_LOCAL_APPDATA": 28,
- }[csidl_name]
-
- buf = ctypes.create_unicode_buffer(1024)
- ctypes.windll.shell32.SHGetFolderPathW(None, csidl_const, None, 0, buf)
-
- # Downgrade to short path name if have highbit chars. See
- # .
- has_high_char = False
- for c in buf:
- if ord(c) > 255:
- has_high_char = True
- break
- if has_high_char:
- buf2 = ctypes.create_unicode_buffer(1024)
- if ctypes.windll.kernel32.GetShortPathNameW(buf.value, buf2, 1024):
- buf = buf2
-
- return buf.value
-
-def _get_win_folder_with_jna(csidl_name):
- import array
- from com.sun import jna
- from com.sun.jna.platform import win32
-
- buf_size = win32.WinDef.MAX_PATH * 2
- buf = array.zeros('c', buf_size)
- shell = win32.Shell32.INSTANCE
- shell.SHGetFolderPath(None, getattr(win32.ShlObj, csidl_name), None, win32.ShlObj.SHGFP_TYPE_CURRENT, buf)
- dir = jna.Native.toString(buf.tostring()).rstrip("\0")
-
- # Downgrade to short path name if have highbit chars. See
- # .
- has_high_char = False
- for c in dir:
- if ord(c) > 255:
- has_high_char = True
- break
- if has_high_char:
- buf = array.zeros('c', buf_size)
- kernel = win32.Kernel32.INSTANCE
- if kernel.GetShortPathName(dir, buf, buf_size):
- dir = jna.Native.toString(buf.tostring()).rstrip("\0")
-
- return dir
-
-if system == "win32":
- try:
- import win32com.shell
- _get_win_folder = _get_win_folder_with_pywin32
- except ImportError:
- try:
- from ctypes import windll
- _get_win_folder = _get_win_folder_with_ctypes
- except ImportError:
- try:
- import com.sun.jna
- _get_win_folder = _get_win_folder_with_jna
- except ImportError:
- _get_win_folder = _get_win_folder_from_registry
-
-
-#---- self test code
-
-if __name__ == "__main__":
- appname = "MyApp"
- appauthor = "MyCompany"
-
- props = ("user_data_dir",
- "user_config_dir",
- "user_cache_dir",
- "user_state_dir",
- "user_log_dir",
- "site_data_dir",
- "site_config_dir")
-
- print("-- app dirs %s --" % __version__)
-
- print("-- app dirs (with optional 'version')")
- dirs = AppDirs(appname, appauthor, version="1.0")
- for prop in props:
- print("%s: %s" % (prop, getattr(dirs, prop)))
-
- print("\n-- app dirs (without optional 'version')")
- dirs = AppDirs(appname, appauthor)
- for prop in props:
- print("%s: %s" % (prop, getattr(dirs, prop)))
-
- print("\n-- app dirs (without optional 'appauthor')")
- dirs = AppDirs(appname)
- for prop in props:
- print("%s: %s" % (prop, getattr(dirs, prop)))
-
- print("\n-- app dirs (with disabled 'appauthor')")
- dirs = AppDirs(appname, appauthor=False)
- for prop in props:
- print("%s: %s" % (prop, getattr(dirs, prop)))
diff --git a/lib/platformdirs/__init__.py b/lib/platformdirs/__init__.py
new file mode 100644
index 00000000..3d5a5bda
--- /dev/null
+++ b/lib/platformdirs/__init__.py
@@ -0,0 +1,628 @@
+"""
+Utilities for determining application-specific dirs. See for details and
+usage.
+"""
+from __future__ import annotations
+
+import os
+import sys
+from typing import TYPE_CHECKING
+
+from .api import PlatformDirsABC
+from .version import __version__
+from .version import __version_tuple__ as __version_info__
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ if sys.version_info >= (3, 8): # pragma: no cover (py38+)
+ from typing import Literal
+ else: # pragma: no cover (py38+)
+ from typing_extensions import Literal
+
+
+def _set_platform_dir_class() -> type[PlatformDirsABC]:
+ if sys.platform == "win32":
+ from platformdirs.windows import Windows as Result
+ elif sys.platform == "darwin":
+ from platformdirs.macos import MacOS as Result
+ else:
+ from platformdirs.unix import Unix as Result
+
+ if os.getenv("ANDROID_DATA") == "/data" and os.getenv("ANDROID_ROOT") == "/system":
+ if os.getenv("SHELL") or os.getenv("PREFIX"):
+ return Result
+
+ from platformdirs.android import _android_folder
+
+ if _android_folder() is not None:
+ from platformdirs.android import Android
+
+ return Android # return to avoid redefinition of result
+
+ return Result
+
+
+PlatformDirs = _set_platform_dir_class() #: Currently active platform
+AppDirs = PlatformDirs #: Backwards compatibility with appdirs
+
+
+def user_data_dir(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ roaming: bool = False, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> str:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param roaming: See `roaming `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: data directory tied to the user
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ roaming=roaming,
+ ensure_exists=ensure_exists,
+ ).user_data_dir
+
+
+def site_data_dir(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ multipath: bool = False, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> str:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param multipath: See `roaming `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: data directory shared by users
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ multipath=multipath,
+ ensure_exists=ensure_exists,
+ ).site_data_dir
+
+
+def user_config_dir(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ roaming: bool = False, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> str:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param roaming: See `roaming `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: config directory tied to the user
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ roaming=roaming,
+ ensure_exists=ensure_exists,
+ ).user_config_dir
+
+
+def site_config_dir(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ multipath: bool = False, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> str:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param multipath: See `roaming `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: config directory shared by the users
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ multipath=multipath,
+ ensure_exists=ensure_exists,
+ ).site_config_dir
+
+
+def user_cache_dir(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ opinion: bool = True, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> str:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param opinion: See `roaming `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: cache directory tied to the user
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ opinion=opinion,
+ ensure_exists=ensure_exists,
+ ).user_cache_dir
+
+
+def site_cache_dir(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ opinion: bool = True, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> str:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param opinion: See `opinion `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: cache directory tied to the user
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ opinion=opinion,
+ ensure_exists=ensure_exists,
+ ).site_cache_dir
+
+
+def user_state_dir(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ roaming: bool = False, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> str:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param roaming: See `roaming `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: state directory tied to the user
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ roaming=roaming,
+ ensure_exists=ensure_exists,
+ ).user_state_dir
+
+
+def user_log_dir(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ opinion: bool = True, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> str:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param opinion: See `roaming `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: log directory tied to the user
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ opinion=opinion,
+ ensure_exists=ensure_exists,
+ ).user_log_dir
+
+
+def user_documents_dir() -> str:
+ """:returns: documents directory tied to the user"""
+ return PlatformDirs().user_documents_dir
+
+
+def user_downloads_dir() -> str:
+ """:returns: downloads directory tied to the user"""
+ return PlatformDirs().user_downloads_dir
+
+
+def user_pictures_dir() -> str:
+ """:returns: pictures directory tied to the user"""
+ return PlatformDirs().user_pictures_dir
+
+
+def user_videos_dir() -> str:
+ """:returns: videos directory tied to the user"""
+ return PlatformDirs().user_videos_dir
+
+
+def user_music_dir() -> str:
+ """:returns: music directory tied to the user"""
+ return PlatformDirs().user_music_dir
+
+
+def user_desktop_dir() -> str:
+ """:returns: desktop directory tied to the user"""
+ return PlatformDirs().user_desktop_dir
+
+
+def user_runtime_dir(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ opinion: bool = True, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> str:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param opinion: See `opinion `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: runtime directory tied to the user
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ opinion=opinion,
+ ensure_exists=ensure_exists,
+ ).user_runtime_dir
+
+
+def site_runtime_dir(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ opinion: bool = True, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> str:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param opinion: See `opinion `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: runtime directory shared by users
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ opinion=opinion,
+ ensure_exists=ensure_exists,
+ ).site_runtime_dir
+
+
+def user_data_path(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ roaming: bool = False, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> Path:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param roaming: See `roaming `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: data path tied to the user
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ roaming=roaming,
+ ensure_exists=ensure_exists,
+ ).user_data_path
+
+
+def site_data_path(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ multipath: bool = False, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> Path:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param multipath: See `multipath `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: data path shared by users
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ multipath=multipath,
+ ensure_exists=ensure_exists,
+ ).site_data_path
+
+
+def user_config_path(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ roaming: bool = False, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> Path:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param roaming: See `roaming `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: config path tied to the user
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ roaming=roaming,
+ ensure_exists=ensure_exists,
+ ).user_config_path
+
+
+def site_config_path(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ multipath: bool = False, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> Path:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param multipath: See `roaming `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: config path shared by the users
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ multipath=multipath,
+ ensure_exists=ensure_exists,
+ ).site_config_path
+
+
+def site_cache_path(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ opinion: bool = True, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> Path:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param opinion: See `opinion `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: cache directory tied to the user
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ opinion=opinion,
+ ensure_exists=ensure_exists,
+ ).site_cache_path
+
+
+def user_cache_path(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ opinion: bool = True, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> Path:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param opinion: See `roaming `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: cache path tied to the user
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ opinion=opinion,
+ ensure_exists=ensure_exists,
+ ).user_cache_path
+
+
+def user_state_path(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ roaming: bool = False, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> Path:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param roaming: See `roaming `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: state path tied to the user
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ roaming=roaming,
+ ensure_exists=ensure_exists,
+ ).user_state_path
+
+
+def user_log_path(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ opinion: bool = True, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> Path:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param opinion: See `roaming `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: log path tied to the user
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ opinion=opinion,
+ ensure_exists=ensure_exists,
+ ).user_log_path
+
+
+def user_documents_path() -> Path:
+ """:returns: documents path tied to the user"""
+ return PlatformDirs().user_documents_path
+
+
+def user_downloads_path() -> Path:
+ """:returns: downloads path tied to the user"""
+ return PlatformDirs().user_downloads_path
+
+
+def user_pictures_path() -> Path:
+ """:returns: pictures path tied to the user"""
+ return PlatformDirs().user_pictures_path
+
+
+def user_videos_path() -> Path:
+ """:returns: videos path tied to the user"""
+ return PlatformDirs().user_videos_path
+
+
+def user_music_path() -> Path:
+ """:returns: music path tied to the user"""
+ return PlatformDirs().user_music_path
+
+
+def user_desktop_path() -> Path:
+ """:returns: desktop path tied to the user"""
+ return PlatformDirs().user_desktop_path
+
+
+def user_runtime_path(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ opinion: bool = True, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> Path:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param opinion: See `opinion `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: runtime path tied to the user
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ opinion=opinion,
+ ensure_exists=ensure_exists,
+ ).user_runtime_path
+
+
+def site_runtime_path(
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ opinion: bool = True, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+) -> Path:
+ """
+ :param appname: See `appname `.
+ :param appauthor: See `appauthor `.
+ :param version: See `version `.
+ :param opinion: See `opinion `.
+ :param ensure_exists: See `ensure_exists `.
+ :returns: runtime path shared by users
+ """
+ return PlatformDirs(
+ appname=appname,
+ appauthor=appauthor,
+ version=version,
+ opinion=opinion,
+ ensure_exists=ensure_exists,
+ ).site_runtime_path
+
+
+__all__ = [
+ "__version__",
+ "__version_info__",
+ "PlatformDirs",
+ "AppDirs",
+ "PlatformDirsABC",
+ "user_data_dir",
+ "user_config_dir",
+ "user_cache_dir",
+ "user_state_dir",
+ "user_log_dir",
+ "user_documents_dir",
+ "user_downloads_dir",
+ "user_pictures_dir",
+ "user_videos_dir",
+ "user_music_dir",
+ "user_desktop_dir",
+ "user_runtime_dir",
+ "site_data_dir",
+ "site_config_dir",
+ "site_cache_dir",
+ "site_runtime_dir",
+ "user_data_path",
+ "user_config_path",
+ "user_cache_path",
+ "user_state_path",
+ "user_log_path",
+ "user_documents_path",
+ "user_downloads_path",
+ "user_pictures_path",
+ "user_videos_path",
+ "user_music_path",
+ "user_desktop_path",
+ "user_runtime_path",
+ "site_data_path",
+ "site_config_path",
+ "site_cache_path",
+ "site_runtime_path",
+]
diff --git a/lib/platformdirs/__main__.py b/lib/platformdirs/__main__.py
new file mode 100644
index 00000000..3cefedbd
--- /dev/null
+++ b/lib/platformdirs/__main__.py
@@ -0,0 +1,54 @@
+"""Main entry point."""
+from __future__ import annotations
+
+from platformdirs import PlatformDirs, __version__
+
+PROPS = (
+ "user_data_dir",
+ "user_config_dir",
+ "user_cache_dir",
+ "user_state_dir",
+ "user_log_dir",
+ "user_documents_dir",
+ "user_downloads_dir",
+ "user_pictures_dir",
+ "user_videos_dir",
+ "user_music_dir",
+ "user_runtime_dir",
+ "site_data_dir",
+ "site_config_dir",
+ "site_cache_dir",
+ "site_runtime_dir",
+)
+
+
+def main() -> None:
+ """Run main entry point."""
+ app_name = "MyApp"
+ app_author = "MyCompany"
+
+ print(f"-- platformdirs {__version__} --") # noqa: T201
+
+ print("-- app dirs (with optional 'version')") # noqa: T201
+ dirs = PlatformDirs(app_name, app_author, version="1.0")
+ for prop in PROPS:
+ print(f"{prop}: {getattr(dirs, prop)}") # noqa: T201
+
+ print("\n-- app dirs (without optional 'version')") # noqa: T201
+ dirs = PlatformDirs(app_name, app_author)
+ for prop in PROPS:
+ print(f"{prop}: {getattr(dirs, prop)}") # noqa: T201
+
+ print("\n-- app dirs (without optional 'appauthor')") # noqa: T201
+ dirs = PlatformDirs(app_name)
+ for prop in PROPS:
+ print(f"{prop}: {getattr(dirs, prop)}") # noqa: T201
+
+ print("\n-- app dirs (with disabled 'appauthor')") # noqa: T201
+ dirs = PlatformDirs(app_name, appauthor=False)
+ for prop in PROPS:
+ print(f"{prop}: {getattr(dirs, prop)}") # noqa: T201
+
+
+if __name__ == "__main__":
+ main()
diff --git a/lib/platformdirs/android.py b/lib/platformdirs/android.py
new file mode 100644
index 00000000..572559f8
--- /dev/null
+++ b/lib/platformdirs/android.py
@@ -0,0 +1,220 @@
+"""Android."""
+from __future__ import annotations
+
+import os
+import re
+import sys
+from functools import lru_cache
+from typing import cast
+
+from .api import PlatformDirsABC
+
+
+class Android(PlatformDirsABC):
+ """
+ Follows the guidance `from here `_. Makes use of the
+ `appname `,
+ `version `,
+ `ensure_exists `.
+ """
+
+ @property
+ def user_data_dir(self) -> str:
+ """:return: data directory tied to the user, e.g. ``/data/user///files/``"""
+ return self._append_app_name_and_version(cast(str, _android_folder()), "files")
+
+ @property
+ def site_data_dir(self) -> str:
+ """:return: data directory shared by users, same as `user_data_dir`"""
+ return self.user_data_dir
+
+ @property
+ def user_config_dir(self) -> str:
+ """
+ :return: config directory tied to the user, e.g. \
+ ``/data/user///shared_prefs/``
+ """
+ return self._append_app_name_and_version(cast(str, _android_folder()), "shared_prefs")
+
+ @property
+ def site_config_dir(self) -> str:
+ """:return: config directory shared by the users, same as `user_config_dir`"""
+ return self.user_config_dir
+
+ @property
+ def user_cache_dir(self) -> str:
+ """:return: cache directory tied to the user, e.g. e.g. ``/data/user///cache/``"""
+ return self._append_app_name_and_version(cast(str, _android_folder()), "cache")
+
+ @property
+ def site_cache_dir(self) -> str:
+ """:return: cache directory shared by users, same as `user_cache_dir`"""
+ return self.user_cache_dir
+
+ @property
+ def user_state_dir(self) -> str:
+ """:return: state directory tied to the user, same as `user_data_dir`"""
+ return self.user_data_dir
+
+ @property
+ def user_log_dir(self) -> str:
+ """
+ :return: log directory tied to the user, same as `user_cache_dir` if not opinionated else ``log`` in it,
+ e.g. ``/data/user///cache//log``
+ """
+ path = self.user_cache_dir
+ if self.opinion:
+ path = os.path.join(path, "log") # noqa: PTH118
+ return path
+
+ @property
+ def user_documents_dir(self) -> str:
+ """:return: documents directory tied to the user e.g. ``/storage/emulated/0/Documents``"""
+ return _android_documents_folder()
+
+ @property
+ def user_downloads_dir(self) -> str:
+ """:return: downloads directory tied to the user e.g. ``/storage/emulated/0/Downloads``"""
+ return _android_downloads_folder()
+
+ @property
+ def user_pictures_dir(self) -> str:
+ """:return: pictures directory tied to the user e.g. ``/storage/emulated/0/Pictures``"""
+ return _android_pictures_folder()
+
+ @property
+ def user_videos_dir(self) -> str:
+ """:return: videos directory tied to the user e.g. ``/storage/emulated/0/DCIM/Camera``"""
+ return _android_videos_folder()
+
+ @property
+ def user_music_dir(self) -> str:
+ """:return: music directory tied to the user e.g. ``/storage/emulated/0/Music``"""
+ return _android_music_folder()
+
+ @property
+ def user_desktop_dir(self) -> str:
+ """:return: desktop directory tied to the user e.g. ``/storage/emulated/0/Desktop``"""
+ return "/storage/emulated/0/Desktop"
+
+ @property
+ def user_runtime_dir(self) -> str:
+ """
+ :return: runtime directory tied to the user, same as `user_cache_dir` if not opinionated else ``tmp`` in it,
+ e.g. ``/data/user///cache//tmp``
+ """
+ path = self.user_cache_dir
+ if self.opinion:
+ path = os.path.join(path, "tmp") # noqa: PTH118
+ return path
+
+ @property
+ def site_runtime_dir(self) -> str:
+ """:return: runtime directory shared by users, same as `user_runtime_dir`"""
+ return self.user_runtime_dir
+
+
+@lru_cache(maxsize=1)
+def _android_folder() -> str | None:
+ """:return: base folder for the Android OS or None if it cannot be found"""
+ try:
+ # First try to get path to android app via pyjnius
+ from jnius import autoclass
+
+ context = autoclass("android.content.Context")
+ result: str | None = context.getFilesDir().getParentFile().getAbsolutePath()
+ except Exception: # noqa: BLE001
+ # if fails find an android folder looking path on the sys.path
+ pattern = re.compile(r"/data/(data|user/\d+)/(.+)/files")
+ for path in sys.path:
+ if pattern.match(path):
+ result = path.split("/files")[0]
+ break
+ else:
+ result = None
+ return result
+
+
+@lru_cache(maxsize=1)
+def _android_documents_folder() -> str:
+ """:return: documents folder for the Android OS"""
+ # Get directories with pyjnius
+ try:
+ from jnius import autoclass
+
+ context = autoclass("android.content.Context")
+ environment = autoclass("android.os.Environment")
+ documents_dir: str = context.getExternalFilesDir(environment.DIRECTORY_DOCUMENTS).getAbsolutePath()
+ except Exception: # noqa: BLE001
+ documents_dir = "/storage/emulated/0/Documents"
+
+ return documents_dir
+
+
+@lru_cache(maxsize=1)
+def _android_downloads_folder() -> str:
+ """:return: downloads folder for the Android OS"""
+ # Get directories with pyjnius
+ try:
+ from jnius import autoclass
+
+ context = autoclass("android.content.Context")
+ environment = autoclass("android.os.Environment")
+ downloads_dir: str = context.getExternalFilesDir(environment.DIRECTORY_DOWNLOADS).getAbsolutePath()
+ except Exception: # noqa: BLE001
+ downloads_dir = "/storage/emulated/0/Downloads"
+
+ return downloads_dir
+
+
+@lru_cache(maxsize=1)
+def _android_pictures_folder() -> str:
+ """:return: pictures folder for the Android OS"""
+ # Get directories with pyjnius
+ try:
+ from jnius import autoclass
+
+ context = autoclass("android.content.Context")
+ environment = autoclass("android.os.Environment")
+ pictures_dir: str = context.getExternalFilesDir(environment.DIRECTORY_PICTURES).getAbsolutePath()
+ except Exception: # noqa: BLE001
+ pictures_dir = "/storage/emulated/0/Pictures"
+
+ return pictures_dir
+
+
+@lru_cache(maxsize=1)
+def _android_videos_folder() -> str:
+ """:return: videos folder for the Android OS"""
+ # Get directories with pyjnius
+ try:
+ from jnius import autoclass
+
+ context = autoclass("android.content.Context")
+ environment = autoclass("android.os.Environment")
+ videos_dir: str = context.getExternalFilesDir(environment.DIRECTORY_DCIM).getAbsolutePath()
+ except Exception: # noqa: BLE001
+ videos_dir = "/storage/emulated/0/DCIM/Camera"
+
+ return videos_dir
+
+
+@lru_cache(maxsize=1)
+def _android_music_folder() -> str:
+ """:return: music folder for the Android OS"""
+ # Get directories with pyjnius
+ try:
+ from jnius import autoclass
+
+ context = autoclass("android.content.Context")
+ environment = autoclass("android.os.Environment")
+ music_dir: str = context.getExternalFilesDir(environment.DIRECTORY_MUSIC).getAbsolutePath()
+ except Exception: # noqa: BLE001
+ music_dir = "/storage/emulated/0/Music"
+
+ return music_dir
+
+
+__all__ = [
+ "Android",
+]
diff --git a/lib/platformdirs/api.py b/lib/platformdirs/api.py
new file mode 100644
index 00000000..aa9ce7b9
--- /dev/null
+++ b/lib/platformdirs/api.py
@@ -0,0 +1,243 @@
+"""Base API."""
+from __future__ import annotations
+
+import os
+from abc import ABC, abstractmethod
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import sys
+
+ if sys.version_info >= (3, 8): # pragma: no cover (py38+)
+ from typing import Literal
+ else: # pragma: no cover (py38+)
+ from typing_extensions import Literal
+
+
+class PlatformDirsABC(ABC):
+ """Abstract base class for platform directories."""
+
+ def __init__( # noqa: PLR0913
+ self,
+ appname: str | None = None,
+ appauthor: str | None | Literal[False] = None,
+ version: str | None = None,
+ roaming: bool = False, # noqa: FBT001, FBT002
+ multipath: bool = False, # noqa: FBT001, FBT002
+ opinion: bool = True, # noqa: FBT001, FBT002
+ ensure_exists: bool = False, # noqa: FBT001, FBT002
+ ) -> None:
+ """
+ Create a new platform directory.
+
+ :param appname: See `appname`.
+ :param appauthor: See `appauthor`.
+ :param version: See `version`.
+ :param roaming: See `roaming`.
+ :param multipath: See `multipath`.
+ :param opinion: See `opinion`.
+ :param ensure_exists: See `ensure_exists`.
+ """
+ self.appname = appname #: The name of application.
+ self.appauthor = appauthor
+ """
+ The name of the app author or distributing body for this application. Typically, it is the owning company name.
+ Defaults to `appname`. You may pass ``False`` to disable it.
+ """
+ self.version = version
+ """
+ An optional version path element to append to the path. You might want to use this if you want multiple versions
+ of your app to be able to run independently. If used, this would typically be ``.``.
+ """
+ self.roaming = roaming
+ """
+ Whether to use the roaming appdata directory on Windows. That means that for users on a Windows network setup
+ for roaming profiles, this user data will be synced on login (see
+ `here `_).
+ """
+ self.multipath = multipath
+ """
+ An optional parameter which indicates that the entire list of data dirs should be returned.
+ By default, the first item would only be returned.
+ """
+ self.opinion = opinion #: A flag to indicating to use opinionated values.
+ self.ensure_exists = ensure_exists
+ """
+ Optionally create the directory (and any missing parents) upon access if it does not exist.
+ By default, no directories are created.
+ """
+
+ def _append_app_name_and_version(self, *base: str) -> str:
+ params = list(base[1:])
+ if self.appname:
+ params.append(self.appname)
+ if self.version:
+ params.append(self.version)
+ path = os.path.join(base[0], *params) # noqa: PTH118
+ self._optionally_create_directory(path)
+ return path
+
+ def _optionally_create_directory(self, path: str) -> None:
+ if self.ensure_exists:
+ Path(path).mkdir(parents=True, exist_ok=True)
+
+ @property
+ @abstractmethod
+ def user_data_dir(self) -> str:
+ """:return: data directory tied to the user"""
+
+ @property
+ @abstractmethod
+ def site_data_dir(self) -> str:
+ """:return: data directory shared by users"""
+
+ @property
+ @abstractmethod
+ def user_config_dir(self) -> str:
+ """:return: config directory tied to the user"""
+
+ @property
+ @abstractmethod
+ def site_config_dir(self) -> str:
+ """:return: config directory shared by the users"""
+
+ @property
+ @abstractmethod
+ def user_cache_dir(self) -> str:
+ """:return: cache directory tied to the user"""
+
+ @property
+ @abstractmethod
+ def site_cache_dir(self) -> str:
+ """:return: cache directory shared by users"""
+
+ @property
+ @abstractmethod
+ def user_state_dir(self) -> str:
+ """:return: state directory tied to the user"""
+
+ @property
+ @abstractmethod
+ def user_log_dir(self) -> str:
+ """:return: log directory tied to the user"""
+
+ @property
+ @abstractmethod
+ def user_documents_dir(self) -> str:
+ """:return: documents directory tied to the user"""
+
+ @property
+ @abstractmethod
+ def user_downloads_dir(self) -> str:
+ """:return: downloads directory tied to the user"""
+
+ @property
+ @abstractmethod
+ def user_pictures_dir(self) -> str:
+ """:return: pictures directory tied to the user"""
+
+ @property
+ @abstractmethod
+ def user_videos_dir(self) -> str:
+ """:return: videos directory tied to the user"""
+
+ @property
+ @abstractmethod
+ def user_music_dir(self) -> str:
+ """:return: music directory tied to the user"""
+
+ @property
+ @abstractmethod
+ def user_desktop_dir(self) -> str:
+ """:return: desktop directory tied to the user"""
+
+ @property
+ @abstractmethod
+ def user_runtime_dir(self) -> str:
+ """:return: runtime directory tied to the user"""
+
+ @property
+ @abstractmethod
+ def site_runtime_dir(self) -> str:
+ """:return: runtime directory shared by users"""
+
+ @property
+ def user_data_path(self) -> Path:
+ """:return: data path tied to the user"""
+ return Path(self.user_data_dir)
+
+ @property
+ def site_data_path(self) -> Path:
+ """:return: data path shared by users"""
+ return Path(self.site_data_dir)
+
+ @property
+ def user_config_path(self) -> Path:
+ """:return: config path tied to the user"""
+ return Path(self.user_config_dir)
+
+ @property
+ def site_config_path(self) -> Path:
+ """:return: config path shared by the users"""
+ return Path(self.site_config_dir)
+
+ @property
+ def user_cache_path(self) -> Path:
+ """:return: cache path tied to the user"""
+ return Path(self.user_cache_dir)
+
+ @property
+ def site_cache_path(self) -> Path:
+ """:return: cache path shared by users"""
+ return Path(self.site_cache_dir)
+
+ @property
+ def user_state_path(self) -> Path:
+ """:return: state path tied to the user"""
+ return Path(self.user_state_dir)
+
+ @property
+ def user_log_path(self) -> Path:
+ """:return: log path tied to the user"""
+ return Path(self.user_log_dir)
+
+ @property
+ def user_documents_path(self) -> Path:
+ """:return: documents path tied to the user"""
+ return Path(self.user_documents_dir)
+
+ @property
+ def user_downloads_path(self) -> Path:
+ """:return: downloads path tied to the user"""
+ return Path(self.user_downloads_dir)
+
+ @property
+ def user_pictures_path(self) -> Path:
+ """:return: pictures path tied to the user"""
+ return Path(self.user_pictures_dir)
+
+ @property
+ def user_videos_path(self) -> Path:
+ """:return: videos path tied to the user"""
+ return Path(self.user_videos_dir)
+
+ @property
+ def user_music_path(self) -> Path:
+ """:return: music path tied to the user"""
+ return Path(self.user_music_dir)
+
+ @property
+ def user_desktop_path(self) -> Path:
+ """:return: desktop path tied to the user"""
+ return Path(self.user_desktop_dir)
+
+ @property
+ def user_runtime_path(self) -> Path:
+ """:return: runtime path tied to the user"""
+ return Path(self.user_runtime_dir)
+
+ @property
+ def site_runtime_path(self) -> Path:
+ """:return: runtime path shared by users"""
+ return Path(self.site_runtime_dir)
diff --git a/lib/platformdirs/macos.py b/lib/platformdirs/macos.py
new file mode 100644
index 00000000..c01ce163
--- /dev/null
+++ b/lib/platformdirs/macos.py
@@ -0,0 +1,126 @@
+"""macOS."""
+from __future__ import annotations
+
+import os.path
+import sys
+
+from .api import PlatformDirsABC
+
+
+class MacOS(PlatformDirsABC):
+ """
+ Platform directories for the macOS operating system. Follows the guidance from `Apple documentation
+ `_.
+ Makes use of the `appname `,
+ `version `,
+ `ensure_exists `.
+ """
+
+ @property
+ def user_data_dir(self) -> str:
+ """:return: data directory tied to the user, e.g. ``~/Library/Application Support/$appname/$version``"""
+ return self._append_app_name_and_version(os.path.expanduser("~/Library/Application Support")) # noqa: PTH111
+
+ @property
+ def site_data_dir(self) -> str:
+ """
+ :return: data directory shared by users, e.g. ``/Library/Application Support/$appname/$version``.
+ If we're using a Python binary managed by `Homebrew `_, the directory
+ will be under the Homebrew prefix, e.g. ``/opt/homebrew/share/$appname/$version``.
+ If `multipath ` is enabled and we're in Homebrew,
+ the response is a multi-path string separated by ":", e.g.
+ ``/opt/homebrew/share/$appname/$version:/Library/Application Support/$appname/$version``
+ """
+ is_homebrew = sys.prefix.startswith("/opt/homebrew")
+ path_list = [self._append_app_name_and_version("/opt/homebrew/share")] if is_homebrew else []
+ path_list.append(self._append_app_name_and_version("/Library/Application Support"))
+ if self.multipath:
+ return os.pathsep.join(path_list)
+ return path_list[0]
+
+ @property
+ def user_config_dir(self) -> str:
+ """:return: config directory tied to the user, same as `user_data_dir`"""
+ return self.user_data_dir
+
+ @property
+ def site_config_dir(self) -> str:
+ """:return: config directory shared by the users, same as `site_data_dir`"""
+ return self.site_data_dir
+
+ @property
+ def user_cache_dir(self) -> str:
+ """:return: cache directory tied to the user, e.g. ``~/Library/Caches/$appname/$version``"""
+ return self._append_app_name_and_version(os.path.expanduser("~/Library/Caches")) # noqa: PTH111
+
+ @property
+ def site_cache_dir(self) -> str:
+ """
+ :return: cache directory shared by users, e.g. ``/Library/Caches/$appname/$version``.
+ If we're using a Python binary managed by `Homebrew `_, the directory
+ will be under the Homebrew prefix, e.g. ``/opt/homebrew/var/cache/$appname/$version``.
+ If `multipath ` is enabled and we're in Homebrew,
+ the response is a multi-path string separated by ":", e.g.
+ ``/opt/homebrew/var/cache/$appname/$version:/Library/Caches/$appname/$version``
+ """
+ is_homebrew = sys.prefix.startswith("/opt/homebrew")
+ path_list = [self._append_app_name_and_version("/opt/homebrew/var/cache")] if is_homebrew else []
+ path_list.append(self._append_app_name_and_version("/Library/Caches"))
+ if self.multipath:
+ return os.pathsep.join(path_list)
+ return path_list[0]
+
+ @property
+ def user_state_dir(self) -> str:
+ """:return: state directory tied to the user, same as `user_data_dir`"""
+ return self.user_data_dir
+
+ @property
+ def user_log_dir(self) -> str:
+ """:return: log directory tied to the user, e.g. ``~/Library/Logs/$appname/$version``"""
+ return self._append_app_name_and_version(os.path.expanduser("~/Library/Logs")) # noqa: PTH111
+
+ @property
+ def user_documents_dir(self) -> str:
+ """:return: documents directory tied to the user, e.g. ``~/Documents``"""
+ return os.path.expanduser("~/Documents") # noqa: PTH111
+
+ @property
+ def user_downloads_dir(self) -> str:
+ """:return: downloads directory tied to the user, e.g. ``~/Downloads``"""
+ return os.path.expanduser("~/Downloads") # noqa: PTH111
+
+ @property
+ def user_pictures_dir(self) -> str:
+ """:return: pictures directory tied to the user, e.g. ``~/Pictures``"""
+ return os.path.expanduser("~/Pictures") # noqa: PTH111
+
+ @property
+ def user_videos_dir(self) -> str:
+ """:return: videos directory tied to the user, e.g. ``~/Movies``"""
+ return os.path.expanduser("~/Movies") # noqa: PTH111
+
+ @property
+ def user_music_dir(self) -> str:
+ """:return: music directory tied to the user, e.g. ``~/Music``"""
+ return os.path.expanduser("~/Music") # noqa: PTH111
+
+ @property
+ def user_desktop_dir(self) -> str:
+ """:return: desktop directory tied to the user, e.g. ``~/Desktop``"""
+ return os.path.expanduser("~/Desktop") # noqa: PTH111
+
+ @property
+ def user_runtime_dir(self) -> str:
+ """:return: runtime directory tied to the user, e.g. ``~/Library/Caches/TemporaryItems/$appname/$version``"""
+ return self._append_app_name_and_version(os.path.expanduser("~/Library/Caches/TemporaryItems")) # noqa: PTH111
+
+ @property
+ def site_runtime_dir(self) -> str:
+ """:return: runtime directory shared by users, same as `user_runtime_dir`"""
+ return self.user_runtime_dir
+
+
+__all__ = [
+ "MacOS",
+]
diff --git a/lib/platformdirs/py.typed b/lib/platformdirs/py.typed
new file mode 100644
index 00000000..e69de29b
diff --git a/lib/platformdirs/unix.py b/lib/platformdirs/unix.py
new file mode 100644
index 00000000..1d0174f2
--- /dev/null
+++ b/lib/platformdirs/unix.py
@@ -0,0 +1,251 @@
+"""Unix."""
+from __future__ import annotations
+
+import os
+import sys
+from configparser import ConfigParser
+from pathlib import Path
+
+from .api import PlatformDirsABC
+
+if sys.platform == "win32":
+
+ def getuid() -> int:
+ msg = "should only be used on Unix"
+ raise RuntimeError(msg)
+
+else:
+ from os import getuid
+
+
+class Unix(PlatformDirsABC):
+ """
+ On Unix/Linux, we follow the
+ `XDG Basedir Spec `_. The spec allows
+ overriding directories with environment variables. The examples show are the default values, alongside the name of
+ the environment variable that overrides them. Makes use of the
+ `appname `,
+ `version `,
+ `multipath `,
+ `opinion `,
+ `ensure_exists `.
+ """
+
+ @property
+ def user_data_dir(self) -> str:
+ """
+ :return: data directory tied to the user, e.g. ``~/.local/share/$appname/$version`` or
+ ``$XDG_DATA_HOME/$appname/$version``
+ """
+ path = os.environ.get("XDG_DATA_HOME", "")
+ if not path.strip():
+ path = os.path.expanduser("~/.local/share") # noqa: PTH111
+ return self._append_app_name_and_version(path)
+
+ @property
+ def site_data_dir(self) -> str:
+ """
+ :return: data directories shared by users (if `multipath ` is
+ enabled and ``XDG_DATA_DIR`` is set and a multi path the response is also a multi path separated by the OS
+ path separator), e.g. ``/usr/local/share/$appname/$version`` or ``/usr/share/$appname/$version``
+ """
+ # XDG default for $XDG_DATA_DIRS; only first, if multipath is False
+ path = os.environ.get("XDG_DATA_DIRS", "")
+ if not path.strip():
+ path = f"/usr/local/share{os.pathsep}/usr/share"
+ return self._with_multi_path(path)
+
+ def _with_multi_path(self, path: str) -> str:
+ path_list = path.split(os.pathsep)
+ if not self.multipath:
+ path_list = path_list[0:1]
+ path_list = [self._append_app_name_and_version(os.path.expanduser(p)) for p in path_list] # noqa: PTH111
+ return os.pathsep.join(path_list)
+
+ @property
+ def user_config_dir(self) -> str:
+ """
+ :return: config directory tied to the user, e.g. ``~/.config/$appname/$version`` or
+ ``$XDG_CONFIG_HOME/$appname/$version``
+ """
+ path = os.environ.get("XDG_CONFIG_HOME", "")
+ if not path.strip():
+ path = os.path.expanduser("~/.config") # noqa: PTH111
+ return self._append_app_name_and_version(path)
+
+ @property
+ def site_config_dir(self) -> str:
+ """
+ :return: config directories shared by users (if `multipath `
+ is enabled and ``XDG_DATA_DIR`` is set and a multi path the response is also a multi path separated by the OS
+ path separator), e.g. ``/etc/xdg/$appname/$version``
+ """
+ # XDG default for $XDG_CONFIG_DIRS only first, if multipath is False
+ path = os.environ.get("XDG_CONFIG_DIRS", "")
+ if not path.strip():
+ path = "/etc/xdg"
+ return self._with_multi_path(path)
+
+ @property
+ def user_cache_dir(self) -> str:
+ """
+ :return: cache directory tied to the user, e.g. ``~/.cache/$appname/$version`` or
+ ``~/$XDG_CACHE_HOME/$appname/$version``
+ """
+ path = os.environ.get("XDG_CACHE_HOME", "")
+ if not path.strip():
+ path = os.path.expanduser("~/.cache") # noqa: PTH111
+ return self._append_app_name_and_version(path)
+
+ @property
+ def site_cache_dir(self) -> str:
+ """:return: cache directory shared by users, e.g. ``/var/tmp/$appname/$version``"""
+ return self._append_app_name_and_version("/var/tmp") # noqa: S108
+
+ @property
+ def user_state_dir(self) -> str:
+ """
+ :return: state directory tied to the user, e.g. ``~/.local/state/$appname/$version`` or
+ ``$XDG_STATE_HOME/$appname/$version``
+ """
+ path = os.environ.get("XDG_STATE_HOME", "")
+ if not path.strip():
+ path = os.path.expanduser("~/.local/state") # noqa: PTH111
+ return self._append_app_name_and_version(path)
+
+ @property
+ def user_log_dir(self) -> str:
+ """:return: log directory tied to the user, same as `user_state_dir` if not opinionated else ``log`` in it"""
+ path = self.user_state_dir
+ if self.opinion:
+ path = os.path.join(path, "log") # noqa: PTH118
+ self._optionally_create_directory(path)
+ return path
+
+ @property
+ def user_documents_dir(self) -> str:
+ """:return: documents directory tied to the user, e.g. ``~/Documents``"""
+ return _get_user_media_dir("XDG_DOCUMENTS_DIR", "~/Documents")
+
+ @property
+ def user_downloads_dir(self) -> str:
+ """:return: downloads directory tied to the user, e.g. ``~/Downloads``"""
+ return _get_user_media_dir("XDG_DOWNLOAD_DIR", "~/Downloads")
+
+ @property
+ def user_pictures_dir(self) -> str:
+ """:return: pictures directory tied to the user, e.g. ``~/Pictures``"""
+ return _get_user_media_dir("XDG_PICTURES_DIR", "~/Pictures")
+
+ @property
+ def user_videos_dir(self) -> str:
+ """:return: videos directory tied to the user, e.g. ``~/Videos``"""
+ return _get_user_media_dir("XDG_VIDEOS_DIR", "~/Videos")
+
+ @property
+ def user_music_dir(self) -> str:
+ """:return: music directory tied to the user, e.g. ``~/Music``"""
+ return _get_user_media_dir("XDG_MUSIC_DIR", "~/Music")
+
+ @property
+ def user_desktop_dir(self) -> str:
+ """:return: desktop directory tied to the user, e.g. ``~/Desktop``"""
+ return _get_user_media_dir("XDG_DESKTOP_DIR", "~/Desktop")
+
+ @property
+ def user_runtime_dir(self) -> str:
+ """
+ :return: runtime directory tied to the user, e.g. ``/run/user/$(id -u)/$appname/$version`` or
+ ``$XDG_RUNTIME_DIR/$appname/$version``.
+
+ For FreeBSD/OpenBSD/NetBSD, it would return ``/var/run/user/$(id -u)/$appname/$version`` if
+ exists, otherwise ``/tmp/runtime-$(id -u)/$appname/$version``, if``$XDG_RUNTIME_DIR``
+ is not set.
+ """
+ path = os.environ.get("XDG_RUNTIME_DIR", "")
+ if not path.strip():
+ if sys.platform.startswith(("freebsd", "openbsd", "netbsd")):
+ path = f"/var/run/user/{getuid()}"
+ if not Path(path).exists():
+ path = f"/tmp/runtime-{getuid()}" # noqa: S108
+ else:
+ path = f"/run/user/{getuid()}"
+ return self._append_app_name_and_version(path)
+
+ @property
+ def site_runtime_dir(self) -> str:
+ """
+ :return: runtime directory shared by users, e.g. ``/run/$appname/$version`` or \
+ ``$XDG_RUNTIME_DIR/$appname/$version``.
+
+ Note that this behaves almost exactly like `user_runtime_dir` if ``$XDG_RUNTIME_DIR`` is set, but will
+ fall back to paths associated to the root user instead of a regular logged-in user if it's not set.
+
+ If you wish to ensure that a logged-in root user path is returned e.g. ``/run/user/0``, use `user_runtime_dir`
+ instead.
+
+ For FreeBSD/OpenBSD/NetBSD, it would return ``/var/run/$appname/$version`` if ``$XDG_RUNTIME_DIR`` is not set.
+ """
+ path = os.environ.get("XDG_RUNTIME_DIR", "")
+ if not path.strip():
+ if sys.platform.startswith(("freebsd", "openbsd", "netbsd")):
+ path = "/var/run"
+ else:
+ path = "/run"
+ return self._append_app_name_and_version(path)
+
+ @property
+ def site_data_path(self) -> Path:
+ """:return: data path shared by users. Only return first item, even if ``multipath`` is set to ``True``"""
+ return self._first_item_as_path_if_multipath(self.site_data_dir)
+
+ @property
+ def site_config_path(self) -> Path:
+ """:return: config path shared by the users. Only return first item, even if ``multipath`` is set to ``True``"""
+ return self._first_item_as_path_if_multipath(self.site_config_dir)
+
+ @property
+ def site_cache_path(self) -> Path:
+ """:return: cache path shared by users. Only return first item, even if ``multipath`` is set to ``True``"""
+ return self._first_item_as_path_if_multipath(self.site_cache_dir)
+
+ def _first_item_as_path_if_multipath(self, directory: str) -> Path:
+ if self.multipath:
+ # If multipath is True, the first path is returned.
+ directory = directory.split(os.pathsep)[0]
+ return Path(directory)
+
+
+def _get_user_media_dir(env_var: str, fallback_tilde_path: str) -> str:
+ media_dir = _get_user_dirs_folder(env_var)
+ if media_dir is None:
+ media_dir = os.environ.get(env_var, "").strip()
+ if not media_dir:
+ media_dir = os.path.expanduser(fallback_tilde_path) # noqa: PTH111
+
+ return media_dir
+
+
+def _get_user_dirs_folder(key: str) -> str | None:
+ """Return directory from user-dirs.dirs config file. See https://freedesktop.org/wiki/Software/xdg-user-dirs/."""
+ user_dirs_config_path = Path(Unix().user_config_dir) / "user-dirs.dirs"
+ if user_dirs_config_path.exists():
+ parser = ConfigParser()
+
+ with user_dirs_config_path.open() as stream:
+ # Add fake section header, so ConfigParser doesn't complain
+ parser.read_string(f"[top]\n{stream.read()}")
+
+ if key not in parser["top"]:
+ return None
+
+ path = parser["top"][key].strip('"')
+ # Handle relative home paths
+ return path.replace("$HOME", os.path.expanduser("~")) # noqa: PTH111
+
+ return None
+
+
+__all__ = [
+ "Unix",
+]
diff --git a/lib/platformdirs/version.py b/lib/platformdirs/version.py
new file mode 100644
index 00000000..c2ef2084
--- /dev/null
+++ b/lib/platformdirs/version.py
@@ -0,0 +1,16 @@
+# file generated by setuptools_scm
+# don't change, don't track in version control
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from typing import Tuple, Union
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
+else:
+ VERSION_TUPLE = object
+
+version: str
+__version__: str
+__version_tuple__: VERSION_TUPLE
+version_tuple: VERSION_TUPLE
+
+__version__ = version = '3.11.0'
+__version_tuple__ = version_tuple = (3, 11, 0)
diff --git a/lib/platformdirs/windows.py b/lib/platformdirs/windows.py
new file mode 100644
index 00000000..751143ad
--- /dev/null
+++ b/lib/platformdirs/windows.py
@@ -0,0 +1,266 @@
+"""Windows."""
+from __future__ import annotations
+
+import ctypes
+import os
+import sys
+from functools import lru_cache
+from typing import TYPE_CHECKING
+
+from .api import PlatformDirsABC
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+
+class Windows(PlatformDirsABC):
+ """
+ `MSDN on where to store app data files
+ `_.
+ Makes use of the
+ `appname `,
+ `appauthor `,
+ `version `,
+ `roaming `,
+ `opinion `,
+ `ensure_exists `.
+ """
+
+ @property
+ def user_data_dir(self) -> str:
+ """
+ :return: data directory tied to the user, e.g.
+ ``%USERPROFILE%\\AppData\\Local\\$appauthor\\$appname`` (not roaming) or
+ ``%USERPROFILE%\\AppData\\Roaming\\$appauthor\\$appname`` (roaming)
+ """
+ const = "CSIDL_APPDATA" if self.roaming else "CSIDL_LOCAL_APPDATA"
+ path = os.path.normpath(get_win_folder(const))
+ return self._append_parts(path)
+
+ def _append_parts(self, path: str, *, opinion_value: str | None = None) -> str:
+ params = []
+ if self.appname:
+ if self.appauthor is not False:
+ author = self.appauthor or self.appname
+ params.append(author)
+ params.append(self.appname)
+ if opinion_value is not None and self.opinion:
+ params.append(opinion_value)
+ if self.version:
+ params.append(self.version)
+ path = os.path.join(path, *params) # noqa: PTH118
+ self._optionally_create_directory(path)
+ return path
+
+ @property
+ def site_data_dir(self) -> str:
+ """:return: data directory shared by users, e.g. ``C:\\ProgramData\\$appauthor\\$appname``"""
+ path = os.path.normpath(get_win_folder("CSIDL_COMMON_APPDATA"))
+ return self._append_parts(path)
+
+ @property
+ def user_config_dir(self) -> str:
+ """:return: config directory tied to the user, same as `user_data_dir`"""
+ return self.user_data_dir
+
+ @property
+ def site_config_dir(self) -> str:
+ """:return: config directory shared by the users, same as `site_data_dir`"""
+ return self.site_data_dir
+
+ @property
+ def user_cache_dir(self) -> str:
+ """
+ :return: cache directory tied to the user (if opinionated with ``Cache`` folder within ``$appname``) e.g.
+ ``%USERPROFILE%\\AppData\\Local\\$appauthor\\$appname\\Cache\\$version``
+ """
+ path = os.path.normpath(get_win_folder("CSIDL_LOCAL_APPDATA"))
+ return self._append_parts(path, opinion_value="Cache")
+
+ @property
+ def site_cache_dir(self) -> str:
+ """:return: cache directory shared by users, e.g. ``C:\\ProgramData\\$appauthor\\$appname\\Cache\\$version``"""
+ path = os.path.normpath(get_win_folder("CSIDL_COMMON_APPDATA"))
+ return self._append_parts(path, opinion_value="Cache")
+
+ @property
+ def user_state_dir(self) -> str:
+ """:return: state directory tied to the user, same as `user_data_dir`"""
+ return self.user_data_dir
+
+ @property
+ def user_log_dir(self) -> str:
+ """:return: log directory tied to the user, same as `user_data_dir` if not opinionated else ``Logs`` in it"""
+ path = self.user_data_dir
+ if self.opinion:
+ path = os.path.join(path, "Logs") # noqa: PTH118
+ self._optionally_create_directory(path)
+ return path
+
+ @property
+ def user_documents_dir(self) -> str:
+ """:return: documents directory tied to the user e.g. ``%USERPROFILE%\\Documents``"""
+ return os.path.normpath(get_win_folder("CSIDL_PERSONAL"))
+
+ @property
+ def user_downloads_dir(self) -> str:
+ """:return: downloads directory tied to the user e.g. ``%USERPROFILE%\\Downloads``"""
+ return os.path.normpath(get_win_folder("CSIDL_DOWNLOADS"))
+
+ @property
+ def user_pictures_dir(self) -> str:
+ """:return: pictures directory tied to the user e.g. ``%USERPROFILE%\\Pictures``"""
+ return os.path.normpath(get_win_folder("CSIDL_MYPICTURES"))
+
+ @property
+ def user_videos_dir(self) -> str:
+ """:return: videos directory tied to the user e.g. ``%USERPROFILE%\\Videos``"""
+ return os.path.normpath(get_win_folder("CSIDL_MYVIDEO"))
+
+ @property
+ def user_music_dir(self) -> str:
+ """:return: music directory tied to the user e.g. ``%USERPROFILE%\\Music``"""
+ return os.path.normpath(get_win_folder("CSIDL_MYMUSIC"))
+
+ @property
+ def user_desktop_dir(self) -> str:
+ """:return: desktop directory tied to the user, e.g. ``%USERPROFILE%\\Desktop``"""
+ return os.path.normpath(get_win_folder("CSIDL_DESKTOPDIRECTORY"))
+
+ @property
+ def user_runtime_dir(self) -> str:
+ """
+ :return: runtime directory tied to the user, e.g.
+ ``%USERPROFILE%\\AppData\\Local\\Temp\\$appauthor\\$appname``
+ """
+ path = os.path.normpath(os.path.join(get_win_folder("CSIDL_LOCAL_APPDATA"), "Temp")) # noqa: PTH118
+ return self._append_parts(path)
+
+ @property
+ def site_runtime_dir(self) -> str:
+ """:return: runtime directory shared by users, same as `user_runtime_dir`"""
+ return self.user_runtime_dir
+
+
+def get_win_folder_from_env_vars(csidl_name: str) -> str:
+ """Get folder from environment variables."""
+ result = get_win_folder_if_csidl_name_not_env_var(csidl_name)
+ if result is not None:
+ return result
+
+ env_var_name = {
+ "CSIDL_APPDATA": "APPDATA",
+ "CSIDL_COMMON_APPDATA": "ALLUSERSPROFILE",
+ "CSIDL_LOCAL_APPDATA": "LOCALAPPDATA",
+ }.get(csidl_name)
+ if env_var_name is None:
+ msg = f"Unknown CSIDL name: {csidl_name}"
+ raise ValueError(msg)
+ result = os.environ.get(env_var_name)
+ if result is None:
+ msg = f"Unset environment variable: {env_var_name}"
+ raise ValueError(msg)
+ return result
+
+
+def get_win_folder_if_csidl_name_not_env_var(csidl_name: str) -> str | None:
+ """Get folder for a CSIDL name that does not exist as an environment variable."""
+ if csidl_name == "CSIDL_PERSONAL":
+ return os.path.join(os.path.normpath(os.environ["USERPROFILE"]), "Documents") # noqa: PTH118
+
+ if csidl_name == "CSIDL_DOWNLOADS":
+ return os.path.join(os.path.normpath(os.environ["USERPROFILE"]), "Downloads") # noqa: PTH118
+
+ if csidl_name == "CSIDL_MYPICTURES":
+ return os.path.join(os.path.normpath(os.environ["USERPROFILE"]), "Pictures") # noqa: PTH118
+
+ if csidl_name == "CSIDL_MYVIDEO":
+ return os.path.join(os.path.normpath(os.environ["USERPROFILE"]), "Videos") # noqa: PTH118
+
+ if csidl_name == "CSIDL_MYMUSIC":
+ return os.path.join(os.path.normpath(os.environ["USERPROFILE"]), "Music") # noqa: PTH118
+ return None
+
+
+def get_win_folder_from_registry(csidl_name: str) -> str:
+ """
+ Get folder from the registry.
+
+ This is a fallback technique at best. I'm not sure if using the registry for these guarantees us the correct answer
+ for all CSIDL_* names.
+ """
+ shell_folder_name = {
+ "CSIDL_APPDATA": "AppData",
+ "CSIDL_COMMON_APPDATA": "Common AppData",
+ "CSIDL_LOCAL_APPDATA": "Local AppData",
+ "CSIDL_PERSONAL": "Personal",
+ "CSIDL_DOWNLOADS": "{374DE290-123F-4565-9164-39C4925E467B}",
+ "CSIDL_MYPICTURES": "My Pictures",
+ "CSIDL_MYVIDEO": "My Video",
+ "CSIDL_MYMUSIC": "My Music",
+ }.get(csidl_name)
+ if shell_folder_name is None:
+ msg = f"Unknown CSIDL name: {csidl_name}"
+ raise ValueError(msg)
+ if sys.platform != "win32": # only needed for mypy type checker to know that this code runs only on Windows
+ raise NotImplementedError
+ import winreg
+
+ key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders")
+ directory, _ = winreg.QueryValueEx(key, shell_folder_name)
+ return str(directory)
+
+
+def get_win_folder_via_ctypes(csidl_name: str) -> str:
+ """Get folder with ctypes."""
+ # There is no 'CSIDL_DOWNLOADS'.
+ # Use 'CSIDL_PROFILE' (40) and append the default folder 'Downloads' instead.
+ # https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid
+
+ csidl_const = {
+ "CSIDL_APPDATA": 26,
+ "CSIDL_COMMON_APPDATA": 35,
+ "CSIDL_LOCAL_APPDATA": 28,
+ "CSIDL_PERSONAL": 5,
+ "CSIDL_MYPICTURES": 39,
+ "CSIDL_MYVIDEO": 14,
+ "CSIDL_MYMUSIC": 13,
+ "CSIDL_DOWNLOADS": 40,
+ "CSIDL_DESKTOPDIRECTORY": 16,
+ }.get(csidl_name)
+ if csidl_const is None:
+ msg = f"Unknown CSIDL name: {csidl_name}"
+ raise ValueError(msg)
+
+ buf = ctypes.create_unicode_buffer(1024)
+ windll = getattr(ctypes, "windll") # noqa: B009 # using getattr to avoid false positive with mypy type checker
+ windll.shell32.SHGetFolderPathW(None, csidl_const, None, 0, buf)
+
+ # Downgrade to short path name if it has high-bit chars.
+ if any(ord(c) > 255 for c in buf): # noqa: PLR2004
+ buf2 = ctypes.create_unicode_buffer(1024)
+ if windll.kernel32.GetShortPathNameW(buf.value, buf2, 1024):
+ buf = buf2
+
+ if csidl_name == "CSIDL_DOWNLOADS":
+ return os.path.join(buf.value, "Downloads") # noqa: PTH118
+
+ return buf.value
+
+
+def _pick_get_win_folder() -> Callable[[str], str]:
+ if hasattr(ctypes, "windll"):
+ return get_win_folder_via_ctypes
+ try:
+ import winreg # noqa: F401
+ except ImportError:
+ return get_win_folder_from_env_vars
+ else:
+ return get_win_folder_from_registry
+
+
+get_win_folder = lru_cache(maxsize=None)(_pick_get_win_folder())
+
+__all__ = [
+ "Windows",
+]
diff --git a/plexpy/common.py b/plexpy/common.py
index 20ddb2df..a4a3be82 100644
--- a/plexpy/common.py
+++ b/plexpy/common.py
@@ -543,13 +543,14 @@ NOTIFICATION_PARAMETERS = [
{'name': 'Duration', 'type': 'int', 'value': 'duration', 'description': 'The duration (in minutes) for the item.'},
{'name': 'Duration (sec)', 'type': 'int', 'value': 'duration_sec', 'description': 'The duration (in seconds) for the item.'},
{'name': 'Duration (ms)', 'type': 'int', 'value': 'duration_ms', 'description': 'The duration (in milliseconds) for the item.'},
+ {'name': 'Duration Time', 'type': 'str', 'value': 'duration_time', 'description': 'The duration (in time format) for the item.'},
{'name': 'Poster URL', 'type': 'str', 'value': 'poster_url', 'description': 'A URL for the movie, TV show, or album poster.'},
{'name': 'Plex ID', 'type': 'str', 'value': 'plex_id', 'description': 'The Plex ID for the item.', 'example': 'e.g. 5d7769a9594b2b001e6a6b7e'},
{'name': 'Plex URL', 'type': 'str', 'value': 'plex_url', 'description': 'The Plex URL to your server for the item.'},
- {'name': 'IMDB ID', 'type': 'str', 'value': 'imdb_id', 'description': 'The IMDB ID for the movie.', 'example': 'e.g. tt2488496'},
- {'name': 'IMDB URL', 'type': 'str', 'value': 'imdb_url', 'description': 'The IMDB URL for the movie.'},
- {'name': 'TVDB ID', 'type': 'int', 'value': 'thetvdb_id', 'description': 'The TVDB ID for the TV show.', 'example': 'e.g. 121361'},
- {'name': 'TVDB URL', 'type': 'str', 'value': 'thetvdb_url', 'description': 'The TVDB URL for the TV show.'},
+ {'name': 'IMDB ID', 'type': 'str', 'value': 'imdb_id', 'description': 'The IMDB ID for the movie or TV show.', 'example': 'e.g. tt2488496'},
+ {'name': 'IMDB URL', 'type': 'str', 'value': 'imdb_url', 'description': 'The IMDB URL for the movie or TV show.'},
+ {'name': 'TVDB ID', 'type': 'int', 'value': 'thetvdb_id', 'description': 'The TVDB ID for the movie or TV show.', 'example': 'e.g. 121361'},
+ {'name': 'TVDB URL', 'type': 'str', 'value': 'thetvdb_url', 'description': 'The TVDB URL for the movie or TV show.'},
{'name': 'TMDB ID', 'type': 'int', 'value': 'themoviedb_id', 'description': 'The TMDb ID for the movie or TV show.', 'example': 'e.g. 15260'},
{'name': 'TMDB URL', 'type': 'str', 'value': 'themoviedb_url', 'description': 'The TMDb URL for the movie or TV show.'},
{'name': 'TVmaze ID', 'type': 'int', 'value': 'tvmaze_id', 'description': 'The TVmaze ID for the TV show.', 'example': 'e.g. 290'},
@@ -599,7 +600,8 @@ NOTIFICATION_PARAMETERS = [
{'name': 'Subtitle Language Code', 'type': 'str', 'value': 'subtitle_language_code', 'description': 'The subtitle language code of the original media.'},
{'name': 'File', 'type': 'str', 'value': 'file', 'description': 'The file path to the item.'},
{'name': 'Filename', 'type': 'str', 'value': 'filename', 'description': 'The file name of the item.'},
- {'name': 'File Size', 'type': 'int', 'value': 'file_size', 'description': 'The file size of the item.'},
+ {'name': 'File Size', 'type': 'str', 'value': 'file_size', 'description': 'The file size of the item in human readable format.', 'example': '1.2 GB'},
+ {'name': 'File Size Bytes', 'type': 'int', 'value': 'file_size_bytes', 'description': 'The file size of the item in bytes.'},
{'name': 'Guid', 'type': 'str', 'value': 'guid', 'description': 'The full guid for the item.'},
{'name': 'Section ID', 'type': 'int', 'value': 'section_id', 'description': 'The unique identifier for the library.'},
{'name': 'Rating Key', 'type': 'int', 'value': 'rating_key', 'description': 'The unique identifier for the movie, episode, or track.'},
@@ -631,7 +633,7 @@ NOTIFICATION_PARAMETERS = [
'parameters': [
{'name': 'Update Version', 'type': 'str', 'value': 'update_version', 'description': 'The available update version for your Plex Server.'},
{'name': 'Update Url', 'type': 'str', 'value': 'update_url', 'description': 'The download URL for the available update.'},
- {'name': 'Update Release Date', 'type': 'str', 'value': 'update_release_date', 'description': 'The release date of the available update.'},
+ {'name': 'Update Release Date', 'type': 'str', 'value': 'update_release_date', 'description': 'The release date (in date format) of the available update.'},
{'name': 'Update Channel', 'type': 'str', 'value': 'update_channel', 'description': 'The update channel.', 'example': 'Public or Plex Pass'},
{'name': 'Update Platform', 'type': 'str', 'value': 'update_platform', 'description': 'The platform of your Plex Server.'},
{'name': 'Update Distro', 'type': 'str', 'value': 'update_distro', 'description': 'The distro of your Plex Server.'},
diff --git a/plexpy/helpers.py b/plexpy/helpers.py
index e38c0619..782280e6 100644
--- a/plexpy/helpers.py
+++ b/plexpy/helpers.py
@@ -24,7 +24,7 @@ from cloudinary.api import delete_resources_by_tag
from cloudinary.uploader import upload
from cloudinary.utils import cloudinary_url
from collections import OrderedDict
-import datetime
+from datetime import date, datetime, timezone
from functools import reduce, wraps
import hashlib
import imghdr
@@ -212,14 +212,14 @@ def timestamp():
def today():
- today = datetime.date.today()
- yyyymmdd = datetime.date.isoformat(today)
+ today = date.today()
+ yyyymmdd = date.isoformat(today)
return yyyymmdd
def utc_now_iso():
- utcnow = datetime.datetime.utcnow()
+ utcnow = datetime.now(tz=timezone.utc).replace(tzinfo=None)
return utcnow.isoformat()
@@ -236,7 +236,7 @@ def timestamp_to_YMDHMS(ts, sep=False):
def timestamp_to_datetime(ts):
- return datetime.datetime.fromtimestamp(ts)
+ return datetime.fromtimestamp(ts)
def iso_to_YMD(iso):
@@ -248,7 +248,7 @@ def iso_to_datetime(iso):
def datetime_to_iso(dt, to_date=False):
- if isinstance(dt, datetime.datetime):
+ if isinstance(dt, datetime):
if to_date:
dt = dt.date()
return dt.isoformat()
diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py
index 6e874d49..0a8651b5 100644
--- a/plexpy/notification_handler.py
+++ b/plexpy/notification_handler.py
@@ -16,6 +16,7 @@
# along with Tautulli. If not, see .
+from typing import Optional
import arrow
import bleach
@@ -680,13 +681,14 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
notify_params['trakt_url'] = 'https://trakt.tv/search/imdb/' + notify_params['imdb_id']
if 'thetvdb://' in notify_params['guid'] or notify_params['thetvdb_id']:
+ thetvdb_media_type = 'movie' if notify_params['media_type'] == 'movie' else 'series'
notify_params['thetvdb_id'] = notify_params['thetvdb_id'] or notify_params['guid'].split('thetvdb://')[1].split('/')[0].split('?')[0]
- notify_params['thetvdb_url'] = 'https://thetvdb.com/?tab=series&id=' + notify_params['thetvdb_id']
+ notify_params['thetvdb_url'] = f'https://thetvdb.com/dereferrer/{thetvdb_media_type}/{notify_params["thetvdb_id"]}'
notify_params['trakt_url'] = 'https://trakt.tv/search/tvdb/' + notify_params['thetvdb_id'] + '?type=show'
elif 'thetvdbdvdorder://' in notify_params['guid']:
notify_params['thetvdb_id'] = notify_params['guid'].split('thetvdbdvdorder://')[1].split('/')[0].split('?')[0]
- notify_params['thetvdb_url'] = 'https://thetvdb.com/?tab=series&id=' + notify_params['thetvdb_id']
+ notify_params['thetvdb_url'] = f'https://thetvdb.com/dereferrer/series/{notify_params["thetvdb_id"]}'
notify_params['trakt_url'] = 'https://trakt.tv/search/tvdb/' + notify_params['thetvdb_id'] + '?type=show'
if 'themoviedb://' in notify_params['guid'] or notify_params['themoviedb_id']:
@@ -787,7 +789,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
notify_params.update(tvmaze_info)
if tvmaze_info.get('thetvdb_id'):
- notify_params['thetvdb_url'] = 'https://thetvdb.com/?tab=series&id=' + str(tvmaze_info['thetvdb_id'])
+ notify_params['thetvdb_url'] = f'https://thetvdb.com/dereferrer/series/{tvmaze_info["thetvdb_id"]}'
notify_params['trakt_url'] = 'https://trakt.tv/search/tvdb/{}' + str(notify_params['thetvdb_id']) + '?type=show'
if tvmaze_info.get('imdb_id'):
notify_params['imdb_url'] = 'https://www.imdb.com/title/' + tvmaze_info['imdb_id']
@@ -963,8 +965,8 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'current_weekday': now_iso[2],
'current_week': now_iso[1],
'week_number': now_iso[1], # Keep for backwards compatibility
- 'datestamp': now.format(date_format),
- 'timestamp': now.format(time_format),
+ 'datestamp': CustomArrow(now, date_format),
+ 'timestamp': CustomArrow(now, time_format),
'unixtime': helpers.timestamp(),
'utctime': helpers.utc_now_iso(),
# Stream parameters
@@ -988,21 +990,21 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'product': notify_params['product'],
'player': notify_params['player'],
'ip_address': notify_params.get('ip_address', 'N/A'),
- 'started_datestamp': arrow.get(notify_params['started']).format(date_format) if notify_params['started'] else '',
- 'started_timestamp': arrow.get(notify_params['started']).format(time_format) if notify_params['started'] else '',
+ 'started_datestamp': CustomArrow(arrow.get(notify_params['started']), date_format) if notify_params['started'] else '',
+ 'started_timestamp': CustomArrow(arrow.get(notify_params['started']), time_format) if notify_params['started'] else '',
'started_unixtime': notify_params['started'],
- 'stopped_datestamp': arrow.get(notify_params['stopped']).format(date_format) if notify_params['stopped'] else '',
- 'stopped_timestamp': arrow.get(notify_params['stopped']).format(time_format) if notify_params['stopped'] else '',
+ 'stopped_datestamp': CustomArrow(arrow.get(notify_params['stopped']), date_format) if notify_params['stopped'] else '',
+ 'stopped_timestamp': CustomArrow(arrow.get(notify_params['stopped']), time_format) if notify_params['stopped'] else '',
'stopped_unixtime': notify_params['stopped'],
'stream_duration': stream_duration,
'stream_duration_sec': stream_duration_sec,
- 'stream_time': arrow.get(stream_duration_sec).format(duration_format),
+ 'stream_time': CustomArrow(arrow.get(stream_duration_sec), duration_format),
'remaining_duration': remaining_duration,
'remaining_duration_sec': remaining_duration_sec,
- 'remaining_time': arrow.get(remaining_duration_sec).format(duration_format),
+ 'remaining_time': CustomArrow(arrow.get(remaining_duration_sec), duration_format),
'progress_duration': progress_duration,
'progress_duration_sec': progress_duration_sec,
- 'progress_time': arrow.get(progress_duration_sec).format(duration_format),
+ 'progress_time': CustomArrow(arrow.get(progress_duration_sec), duration_format),
'progress_percent': helpers.get_percent(progress_duration_sec, duration_sec),
'view_offset': session.get('view_offset', 0),
'initial_stream': notify_params['initial_stream'],
@@ -1110,15 +1112,15 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'track_count': grandchild_count,
'year': notify_params['year'],
'show_year': show_year,
- 'release_date': arrow.get(notify_params['originally_available_at']).format(date_format)
+ 'release_date': CustomArrow(arrow.get(notify_params['originally_available_at']), date_format)
if notify_params['originally_available_at'] else '',
- 'air_date': arrow.get(notify_params['originally_available_at']).format(date_format)
+ 'air_date': CustomArrow(arrow.get(notify_params['originally_available_at']), date_format)
if notify_params['originally_available_at'] else '',
- 'added_date': arrow.get(int(notify_params['added_at'])).format(date_format)
+ 'added_date': CustomArrow(arrow.get(int(notify_params['added_at'])), date_format)
if notify_params['added_at'] else '',
- 'updated_date': arrow.get(int(notify_params['updated_at'])).format(date_format)
+ 'updated_date': CustomArrow(arrow.get(int(notify_params['updated_at'])), date_format)
if notify_params['updated_at'] else '',
- 'last_viewed_date': arrow.get(int(notify_params['last_viewed_at'])).format(date_format)
+ 'last_viewed_date': CustomArrow(arrow.get(int(notify_params['last_viewed_at'])), date_format)
if notify_params['last_viewed_at'] else '',
'studio': notify_params['studio'],
'content_rating': notify_params['content_rating'],
@@ -1137,6 +1139,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'duration': duration,
'duration_sec': duration_sec,
'duration_ms': notify_params['duration'],
+ 'duration_time': CustomArrow(arrow.get(duration_sec), duration_format),
'poster_title': notify_params['poster_title'],
'poster_url': notify_params['poster_url'],
'plex_id': notify_params['plex_id'],
@@ -1195,6 +1198,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'file': notify_params['file'],
'filename': os.path.basename(notify_params['file'].replace('\\', os.sep)),
'file_size': helpers.human_file_size(notify_params['file_size']),
+ 'file_size_bytes': notify_params['file_size'],
'indexes': notify_params['indexes'],
'guid': notify_params['guid'],
'section_id': notify_params['section_id'],
@@ -1258,8 +1262,8 @@ def build_server_notify_params(notify_action=None, **kwargs):
'current_weekday': now_iso[2],
'current_week': now_iso[1],
'week_number': now_iso[1], # Keep for backwards compatibility
- 'datestamp': now.format(date_format),
- 'timestamp': now.format(time_format),
+ 'datestamp': CustomArrow(now, date_format),
+ 'timestamp': CustomArrow(now, time_format),
'unixtime': helpers.timestamp(),
'utctime': helpers.utc_now_iso(),
# Plex remote access parameters
@@ -1273,7 +1277,7 @@ def build_server_notify_params(notify_action=None, **kwargs):
# Plex Media Server update parameters
'update_version': pms_download_info['version'],
'update_url': pms_download_info['download_url'],
- 'update_release_date': arrow.get(pms_download_info['release_date']).format(date_format)
+ 'update_release_date': CustomArrow(arrow.get(pms_download_info['release_date']), date_format)
if pms_download_info['release_date'] else '',
'update_channel': 'Beta' if update_channel == 'beta' else 'Public',
'update_platform': pms_download_info['platform'],
@@ -2069,3 +2073,21 @@ class CustomFormatter(Formatter):
# result.append(self.format_field(obj, format_spec))
return ''.join(result), auto_arg_index
+
+
+class CustomArrow:
+ def __init__(self, arrow_value: arrow.arrow.Arrow, default_format: Optional[str] = None):
+ self.arrow_value = arrow_value
+ self.default_format = default_format
+
+ def __format__(self, formatstr: str) -> str:
+ if len(formatstr) > 0:
+ return self.arrow_value.format(formatstr)
+
+ if self.default_format is not None:
+ return self.__format__(self.default_format)
+
+ return str(self.arrow_value)
+
+ def __str__(self) -> str:
+ return self.__format__('')
diff --git a/plexpy/notifiers.py b/plexpy/notifiers.py
index b80e7f79..f6b07931 100644
--- a/plexpy/notifiers.py
+++ b/plexpy/notifiers.py
@@ -794,6 +794,7 @@ class PrettyMetadata(object):
'plexweb': 'Plex Web',
'imdb': 'IMDB',
'themoviedb': 'The Movie Database',
+ 'thetvdb': 'TheTVDB',
'trakt': 'Trakt.tv'
}
diff --git a/plexpy/version.py b/plexpy/version.py
index e1e2c0d1..2caab495 100644
--- a/plexpy/version.py
+++ b/plexpy/version.py
@@ -17,4 +17,4 @@
PLEXPY_BRANCH = "master"
-PLEXPY_RELEASE_VERSION = "v2.13.2"
\ No newline at end of file
+PLEXPY_RELEASE_VERSION = "v2.13.4"
\ No newline at end of file
diff --git a/plexpy/webauth.py b/plexpy/webauth.py
index afbbec93..1dc43155 100644
--- a/plexpy/webauth.py
+++ b/plexpy/webauth.py
@@ -21,7 +21,7 @@
# Session tool to be loaded.
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from urllib.parse import quote, unquote
import cherrypy
@@ -370,7 +370,7 @@ class AuthController(object):
if valid_login:
time_delta = timedelta(days=30) if remember_me == '1' else timedelta(minutes=60)
- expiry = datetime.utcnow() + time_delta
+ expiry = datetime.now(tz=timezone.utc) + time_delta
payload = {
'user_id': user_details['user_id'],
@@ -391,7 +391,7 @@ class AuthController(object):
jwt_cookie = str(JWT_COOKIE_NAME + plexpy.CONFIG.PMS_UUID)
cherrypy.response.cookie[jwt_cookie] = jwt_token
- cherrypy.response.cookie[jwt_cookie]['expires'] = int(time_delta.total_seconds())
+ cherrypy.response.cookie[jwt_cookie]['max-age'] = int(time_delta.total_seconds())
cherrypy.response.cookie[jwt_cookie]['path'] = plexpy.HTTP_ROOT.rstrip('/') or '/'
cherrypy.response.cookie[jwt_cookie]['httponly'] = True
cherrypy.response.cookie[jwt_cookie]['samesite'] = 'lax'
diff --git a/requirements.txt b/requirements.txt
index fbaa537e..7aaf7d15 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,3 @@
-appdirs==1.4.4
apscheduler==3.10.1
arrow==1.2.3
beautifulsoup4==4.12.2
@@ -24,6 +23,7 @@ MarkupSafe==2.1.3
musicbrainzngs==0.7.1
packaging==23.1
paho-mqtt==1.6.1
+platformdirs==3.11.0
plexapi==4.15.4
portend==3.2.0
profilehooks==1.12.0