From 98583d139ac4207604fd728f3d591c2a79cb4636 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Thu, 26 Oct 2023 09:05:47 -0700
Subject: [PATCH 027/290] Add config override for PMS_LANGUAGE
---
plexpy/config.py | 1 +
plexpy/http_handler.py | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/plexpy/config.py b/plexpy/config.py
index 6f2926d9..dbcd294d 100644
--- a/plexpy/config.py
+++ b/plexpy/config.py
@@ -55,6 +55,7 @@ _CONFIG_DEFINITIONS = {
'PMS_IP': (str, 'PMS', '127.0.0.1'),
'PMS_IS_CLOUD': (int, 'PMS', 0),
'PMS_IS_REMOTE': (int, 'PMS', 0),
+ 'PMS_LANGUAGE': (str, 'PMS', ''),
'PMS_LOGS_FOLDER': (str, 'PMS', ''),
'PMS_LOGS_LINE_CAP': (int, 'PMS', 1000),
'PMS_NAME': (str, 'PMS', ''),
diff --git a/plexpy/http_handler.py b/plexpy/http_handler.py
index 87c4cff5..79bb3562 100644
--- a/plexpy/http_handler.py
+++ b/plexpy/http_handler.py
@@ -62,7 +62,7 @@ class HTTPHandler(object):
plexpy.common.PLATFORM_RELEASE),
'X-Plex-Device-Name': '{} ({})'.format(plexpy.common.PLATFORM_DEVICE_NAME,
plexpy.common.PRODUCT),
- 'Accept-Language': plexpy.SYS_LANGUAGE
+ 'X-Plex-Language': plexpy.CONFIG.PMS_LANGUAGE or plexpy.SYS_LANGUAGE
}
self.token = token
From d63c0cb469375537ddb713368a1e89dde41072ad Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Thu, 26 Oct 2023 09:24:48 -0700
Subject: [PATCH 028/290] Guard against None transcode_key
---
plexpy/activity_handler.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/plexpy/activity_handler.py b/plexpy/activity_handler.py
index 7bfbe8fb..32f835c3 100644
--- a/plexpy/activity_handler.py
+++ b/plexpy/activity_handler.py
@@ -310,7 +310,9 @@ class ActivityHandler(object):
last_state = self.db_session['state']
last_rating_key = str(self.db_session['rating_key'])
last_live_uuid = self.db_session['live_uuid']
- last_transcode_key = self.db_session['transcode_key'].split('/')[-1]
+ last_transcode_key = self.db_session['transcode_key']
+ if isinstance(last_transcode_key, str):
+ last_transcode_key = last_transcode_key.split('/')[-1]
last_paused = self.db_session['last_paused']
last_rating_key_websocket = self.db_session['rating_key_websocket']
last_guid = self.db_session['guid']
From c215afbf84baafb1a2020c2dc6e36944a6de1504 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Thu, 26 Oct 2023 09:27:31 -0700
Subject: [PATCH 029/290] v2.13.2
---
CHANGELOG.md | 14 ++++++++++++++
plexpy/version.py | 2 +-
2 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1266e43f..d5eb4c5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
# Changelog
+## v2.13.2 (2023-10-26)
+* History:
+ * New: Added quarter values icons for history watch status. (#2179, #2156) (Thanks @herby2212)
+* Graphs:
+ * New: Added concurrent streams per day graph. (#2046) (Thanks @herby2212)
+* Exporter:
+ * New: Added metadata directory to exporter fields.
+ * Removed: Banner exporter fields for tv shows.
+* UI:
+ * New: Added last triggered time to notification agents and newsletter agent lists.
+* Other:
+ * New: Added X-Plex-Language header override to config file.
+
+
## v2.13.1 (2023-08-25)
* Notes:
* Support for Python 3.7 has been dropped. The minimum Python version is now 3.8.
diff --git a/plexpy/version.py b/plexpy/version.py
index c5ee1521..5ca70297 100644
--- a/plexpy/version.py
+++ b/plexpy/version.py
@@ -18,4 +18,4 @@
from __future__ import unicode_literals
PLEXPY_BRANCH = "master"
-PLEXPY_RELEASE_VERSION = "v2.13.1"
\ No newline at end of file
+PLEXPY_RELEASE_VERSION = "v2.13.2"
\ No newline at end of file
From dd380b583f67ad112f99057da4518a3df9c7ff74 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Thu, 26 Oct 2023 11:05:34 -0700
Subject: [PATCH 030/290] Add system language to startup logs
---
plexpy/__init__.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/plexpy/__init__.py b/plexpy/__init__.py
index eb1f73c2..2590c867 100644
--- a/plexpy/__init__.py
+++ b/plexpy/__init__.py
@@ -239,6 +239,9 @@ def initialize(config_file):
logger.info("{} (UTC{})".format(
str(SYS_TIMEZONE), SYS_UTC_OFFSET
))
+ logger.info("Language {}{} / Encoding {}".format(
+ SYS_LANGUAGE, f' (override {CONFIG.PMS_LANGUAGE})' if CONFIG.PMS_LANGUAGE else '', SYS_ENCODING
+ ))
logger.info("Python {}".format(
sys.version.replace('\n', '')
))
From 32cf26884b4dcc7fa1aa26260d99689b922deedd Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Thu, 26 Oct 2023 11:05:51 -0700
Subject: [PATCH 031/290] Add system language and sqlite version to
configuration table
---
data/interfaces/default/configuration_table.html | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/data/interfaces/default/configuration_table.html b/data/interfaces/default/configuration_table.html
index 676876d6..66d8ef40 100644
--- a/data/interfaces/default/configuration_table.html
+++ b/data/interfaces/default/configuration_table.html
@@ -11,6 +11,7 @@ DOCUMENTATION :: END
<%!
import os
+ import sqlite3
import sys
import plexpy
from plexpy import common, logger
@@ -71,10 +72,18 @@ DOCUMENTATION :: END
Resources: |
From f1c12c0bbe0a8364bd1c3a3822291dc52f806bfb Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Sat, 4 Nov 2023 13:29:40 -0700
Subject: [PATCH 032/290] Switch appdirs to platformdirs
---
Tautulli.py | 4 +-
lib/appdirs.py | 608 ---------------------------------
lib/platformdirs/__init__.py | 628 +++++++++++++++++++++++++++++++++++
lib/platformdirs/__main__.py | 54 +++
lib/platformdirs/android.py | 220 ++++++++++++
lib/platformdirs/api.py | 243 ++++++++++++++
lib/platformdirs/macos.py | 126 +++++++
lib/platformdirs/py.typed | 0
lib/platformdirs/unix.py | 251 ++++++++++++++
lib/platformdirs/version.py | 16 +
lib/platformdirs/windows.py | 266 +++++++++++++++
requirements.txt | 2 +-
12 files changed, 1807 insertions(+), 611 deletions(-)
delete mode 100644 lib/appdirs.py
create mode 100644 lib/platformdirs/__init__.py
create mode 100644 lib/platformdirs/__main__.py
create mode 100644 lib/platformdirs/android.py
create mode 100644 lib/platformdirs/api.py
create mode 100644 lib/platformdirs/macos.py
create mode 100644 lib/platformdirs/py.typed
create mode 100644 lib/platformdirs/unix.py
create mode 100644 lib/platformdirs/version.py
create mode 100644 lib/platformdirs/windows.py
diff --git a/Tautulli.py b/Tautulli.py
index eebfa55a..04a6778e 100755
--- a/Tautulli.py
+++ b/Tautulli.py
@@ -25,10 +25,10 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'lib
from future.builtins import str
-import appdirs
import argparse
import datetime
import locale
+import platformdirs
import pytz
import signal
import shutil
@@ -186,7 +186,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/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/requirements.txt b/requirements.txt
index 04578ef9..bc0a4bd6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,3 @@
-appdirs==1.4.4
apscheduler==3.10.1
arrow==1.2.3
backports.csv==1.0.7
@@ -28,6 +27,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
From ae17d2dde036e216cf8c626596ad8cb6952ccb94 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Sat, 4 Nov 2023 13:44:49 -0700
Subject: [PATCH 033/290] Switch actions/create-release to
softprops/action-gh-release
* actions/create-release deprecated
---
.github/workflows/publish-installers.yml | 30 ++++--------------------
1 file changed, 5 insertions(+), 25 deletions(-)
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
From ab5836a65b7846be835e148d89bd6de6ec781a4f Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Tue, 7 Nov 2023 17:12:19 -0800
Subject: [PATCH 034/290] Add duration_time notification parameter
---
plexpy/common.py | 1 +
plexpy/notification_handler.py | 1 +
2 files changed, 2 insertions(+)
diff --git a/plexpy/common.py b/plexpy/common.py
index 889d3f73..c692840b 100644
--- a/plexpy/common.py
+++ b/plexpy/common.py
@@ -547,6 +547,7 @@ 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.'},
diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py
index 8b4b8583..b6972268 100644
--- a/plexpy/notification_handler.py
+++ b/plexpy/notification_handler.py
@@ -1155,6 +1155,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'],
+ 'druation_time': arrow.get(duration_sec).format(duration_format),
'poster_title': notify_params['poster_title'],
'poster_url': notify_params['poster_url'],
'plex_id': notify_params['plex_id'],
From 2da3714dd11c915706aa111993c7e946aff278ca Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Tue, 7 Nov 2023 17:18:48 -0800
Subject: [PATCH 035/290] Add CustomArrow date/time formatter
---
plexpy/notification_handler.py | 47 ++++++++++++++++++++++++----------
1 file changed, 33 insertions(+), 14 deletions(-)
diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py
index b6972268..a7336a30 100644
--- a/plexpy/notification_handler.py
+++ b/plexpy/notification_handler.py
@@ -18,6 +18,7 @@
from __future__ import division
from __future__ import unicode_literals
+from typing import Optional
from future.builtins import next
from future.builtins import map
from future.builtins import str
@@ -1006,21 +1007,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'],
@@ -1128,15 +1129,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'],
@@ -1155,7 +1156,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'],
- 'druation_time': arrow.get(duration_sec).format(duration_format),
+ 'druation_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'],
@@ -1292,7 +1293,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'],
@@ -2095,3 +2096,21 @@ class CustomFormatter(Formatter):
return ''.join(result)
else:
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__('')
From 89aad6952b965c71a2c7bae2e698a8d727f91d53 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Thu, 9 Nov 2023 16:43:41 -0800
Subject: [PATCH 036/290] Fix activity card overflow due to screen scaling
* Fixes #2033
---
data/interfaces/default/css/tautulli.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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;
}
From 8fd62e30b3afb88950274ee9a9cfaadc7b0572ec Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Thu, 9 Nov 2023 17:17:24 -0800
Subject: [PATCH 037/290] Fix duration_time typo
---
plexpy/notification_handler.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py
index a7336a30..8f1d6263 100644
--- a/plexpy/notification_handler.py
+++ b/plexpy/notification_handler.py
@@ -1156,7 +1156,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'],
- 'druation_time': CustomArrow(arrow.get(duration_sec), duration_format),
+ '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'],
From 5abdfd7377a6da718a67b4956b8268485e192de8 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Mon, 13 Nov 2023 11:25:39 -0800
Subject: [PATCH 038/290] Make datestamp and timestamp formattable
---
plexpy/notification_handler.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py
index 8f1d6263..4ed08bf6 100644
--- a/plexpy/notification_handler.py
+++ b/plexpy/notification_handler.py
@@ -982,8 +982,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
@@ -1278,8 +1278,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
From 98c363f559365e927b41bde3549bce6b37021df1 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Mon, 13 Nov 2023 11:28:53 -0800
Subject: [PATCH 039/290] Update Notification Text Modifiers modal with Time
Formats
---
data/interfaces/default/settings.html | 15 +++++++++++++++
plexpy/common.py | 2 +-
2 files changed, 16 insertions(+), 1 deletion(-)
diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html
index 6710ff79..e5b0b321 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
diff --git a/plexpy/common.py b/plexpy/common.py
index c692840b..79b76b5c 100644
--- a/plexpy/common.py
+++ b/plexpy/common.py
@@ -636,7 +636,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.'},
From 380cbd232c90290971f2d43e9c8fbbff1777ffa6 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Sun, 19 Nov 2023 13:05:16 -0800
Subject: [PATCH 040/290] Add file_size_bytes notification parameter
* Change type of file_size parameter and update description to indicate it is in human readable format.
---
plexpy/common.py | 3 ++-
plexpy/notification_handler.py | 1 +
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/plexpy/common.py b/plexpy/common.py
index 79b76b5c..b0e22147 100644
--- a/plexpy/common.py
+++ b/plexpy/common.py
@@ -604,7 +604,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.'},
diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py
index 4ed08bf6..14e20750 100644
--- a/plexpy/notification_handler.py
+++ b/plexpy/notification_handler.py
@@ -1215,6 +1215,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'],
From d0c1e467bd42bf3c38733b3bf324a7e6e81bcc39 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Sun, 19 Nov 2023 13:46:57 -0800
Subject: [PATCH 041/290] Add support for thetvdb_url for movies
---
plexpy/common.py | 8 ++++----
plexpy/notification_handler.py | 7 ++++---
plexpy/notifiers.py | 1 +
3 files changed, 9 insertions(+), 7 deletions(-)
diff --git a/plexpy/common.py b/plexpy/common.py
index b0e22147..9496913f 100644
--- a/plexpy/common.py
+++ b/plexpy/common.py
@@ -551,10 +551,10 @@ NOTIFICATION_PARAMETERS = [
{'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'},
diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py
index 14e20750..34a60cd6 100644
--- a/plexpy/notification_handler.py
+++ b/plexpy/notification_handler.py
@@ -699,13 +699,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']:
@@ -806,7 +807,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']
diff --git a/plexpy/notifiers.py b/plexpy/notifiers.py
index 1e18644e..1e890806 100644
--- a/plexpy/notifiers.py
+++ b/plexpy/notifiers.py
@@ -807,6 +807,7 @@ class PrettyMetadata(object):
'plexweb': 'Plex Web',
'imdb': 'IMDB',
'themoviedb': 'The Movie Database',
+ 'thetvdb': 'TheTVDB',
'trakt': 'Trakt.tv'
}
From ddc8a08fc7cea4c31a36a42e283a60e78495c4f6 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Tue, 21 Nov 2023 11:35:54 -0800
Subject: [PATCH 042/290] Replace usage of utcnow()
`datetime.utcnow()` deprecated in Python 3.12
---
plexpy/helpers.py | 12 ++++++------
plexpy/webauth.py | 2 +-
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/plexpy/helpers.py b/plexpy/helpers.py
index 486be759..a3257403 100644
--- a/plexpy/helpers.py
+++ b/plexpy/helpers.py
@@ -28,7 +28,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
@@ -222,14 +222,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()
@@ -246,7 +246,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):
@@ -258,7 +258,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/webauth.py b/plexpy/webauth.py
index 5487f2ea..c3c3f7e2 100644
--- a/plexpy/webauth.py
+++ b/plexpy/webauth.py
@@ -378,7 +378,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() + time_delta
payload = {
'user_id': user_details['user_id'],
From 325271a88eb884f58a646416b6a1a2841371ef8a Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Sun, 3 Dec 2023 13:33:37 -0800
Subject: [PATCH 043/290] Update the stream duration on activity cards
* Fixes #2206
Some clients like Plexamp use the same sessionKey when the track changes.
---
data/interfaces/default/index.html | 1 +
1 file changed, 1 insertion(+)
diff --git a/data/interfaces/default/index.html b/data/interfaces/default/index.html
index 57236e69..4a74b2e4 100644
--- a/data/interfaces/default/index.html
+++ b/data/interfaces/default/index.html
@@ -584,6 +584,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) {
From 98ceb0a81d45f177dbc8cb8cfca86f1d2e585624 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Sun, 3 Dec 2023 13:45:13 -0800
Subject: [PATCH 044/290] Add time formats to order of notification text
modifiers
---
data/interfaces/default/settings.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html
index e5b0b321..883d4d3d 100644
--- a/data/interfaces/default/settings.html
+++ b/data/interfaces/default/settings.html
@@ -2009,7 +2009,7 @@ Rating: {rating}/10 --> Rating: /10
Evaluation
Parameter
Case Modifier
- List Slicing
+ Time Formats / List Slicing
Suffix
Example:
From 8cb74f74806863abbc47419d85b002abb600ea00 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Sun, 3 Dec 2023 13:49:55 -0800
Subject: [PATCH 045/290] v2.13.3
---
CHANGELOG.md | 14 ++++++++++++++
plexpy/version.py | 2 +-
2 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5eb4c5a..ff82a7a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,19 @@
# Changelog
+## 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 +28,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/plexpy/version.py b/plexpy/version.py
index 5ca70297..c52bd955 100644
--- a/plexpy/version.py
+++ b/plexpy/version.py
@@ -18,4 +18,4 @@
from __future__ import unicode_literals
PLEXPY_BRANCH = "master"
-PLEXPY_RELEASE_VERSION = "v2.13.2"
\ No newline at end of file
+PLEXPY_RELEASE_VERSION = "v2.13.3"
\ No newline at end of file
From 5525b9851cd15508da3a4ecce87916445b1bc66e Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Sun, 3 Dec 2023 14:02:51 -0800
Subject: [PATCH 046/290] Fix issue number in changelog
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ff82a7a0..2ee3bb33 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,7 @@
* 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)
+ * Fix: Stream duration on activity card not being updated on track changes in some cases. (#2206)
## v2.13.2 (2023-10-26)
From e3113ebd309739a755c95ec1b8b6d94709d2d016 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Wed, 6 Dec 2023 13:20:33 -0800
Subject: [PATCH 047/290] Fix configuration table None system language
---
data/interfaces/default/configuration_table.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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
|