Update code
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
|
from os.path import join
|
||||||
from . import _
|
from . import _
|
||||||
from .audio import generate_audio_report
|
from .audio import prepare_audio_map, generate_audio_report
|
||||||
from .cache import IdRelativeCache
|
from .cache import IdRelativeCache
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .db import PlaybackReportingDb, LibraryDb
|
from .db import PlaybackReportingDb, LibraryDb, JellyfinDb
|
||||||
|
|
||||||
|
|
||||||
p = ArgumentParser(prog="jellyfinstats")
|
p = ArgumentParser(prog="jellyfinstats")
|
||||||
@@ -13,9 +14,20 @@ p.add_argument("--library-db", help=_("The path to library.db"))
|
|||||||
p.add_argument("--jellyfin-data-dir", help=_("The path to jellyfin data directory.")) # noqa: E501
|
p.add_argument("--jellyfin-data-dir", help=_("The path to jellyfin data directory.")) # noqa: E501
|
||||||
p.add_argument("--output-dir", help=_("The directory for output files."))
|
p.add_argument("--output-dir", help=_("The directory for output files."))
|
||||||
p.add_argument("--ask-page-size", help=_("Specify maximum items to display in one page."), type=int) # noqa: E501
|
p.add_argument("--ask-page-size", help=_("Specify maximum items to display in one page."), type=int) # noqa: E501
|
||||||
|
p.add_argument("--jellyfin-db", help=_("The path to jellyfin.db"))
|
||||||
arg = p.parse_intermixed_args()
|
arg = p.parse_intermixed_args()
|
||||||
cfg = Config(arg.config, arg)
|
cfg = Config(arg.config, arg)
|
||||||
with PlaybackReportingDb(cfg.playback_reporting_db) as pdb:
|
with PlaybackReportingDb(cfg.playback_reporting_db) as pdb:
|
||||||
with LibraryDb(cfg.library_db) as ldb:
|
with LibraryDb(cfg.library_db) as ldb:
|
||||||
with IdRelativeCache(cfg.output_dir) as icache:
|
with IdRelativeCache(cfg.output_dir) as icache:
|
||||||
generate_audio_report(pdb, ldb, icache, cfg)
|
with JellyfinDb(cfg.jellyfin_db) as jdb:
|
||||||
|
re = prepare_audio_map(pdb, ldb, icache, cfg)
|
||||||
|
users = pdb.get_users('Audio')
|
||||||
|
for u in users:
|
||||||
|
userid = u['UserId']
|
||||||
|
user = jdb.get_user(userid)
|
||||||
|
username = user['Username'] if user else userid
|
||||||
|
output = join(cfg.output_dir, 'audio', username)
|
||||||
|
maxDate = u['MaxDate']
|
||||||
|
minDate = u['MinDate']
|
||||||
|
generate_audio_report(pdb, re[0], re[1], output, userid)
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
from . import _
|
from . import _
|
||||||
from .cache import IdRelativeCache
|
from .cache import IdRelativeCache
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
from .csv import CSVFile
|
||||||
from .db import PlaybackReportingDb, LibraryDb
|
from .db import PlaybackReportingDb, LibraryDb
|
||||||
from .utils import ask_choice
|
from .utils import ask_choice, parse_time
|
||||||
|
from datetime import datetime
|
||||||
from re import compile
|
from re import compile
|
||||||
|
from os import makedirs
|
||||||
|
from os.path import join
|
||||||
|
|
||||||
|
|
||||||
ITEMNAME_PATTERN = compile(r'(?P<album_artist>.*) - (?P<track>.*) \((?P<album>.*)\)') # noqa: E501
|
ITEMNAME_PATTERN = compile(r'(?P<album_artist>.*) - (?P<track>.*) \((?P<album>.*)\)') # noqa: E501
|
||||||
@@ -108,40 +112,41 @@ class AudioSelector:
|
|||||||
return self.re
|
return self.re
|
||||||
|
|
||||||
|
|
||||||
def generate_audio_report(pdb: PlaybackReportingDb, ldb: LibraryDb,
|
def prepare_audio_map(pdb: PlaybackReportingDb, ldb: LibraryDb,
|
||||||
icache: IdRelativeCache, cfg: Config):
|
icache: IdRelativeCache, cfg: Config):
|
||||||
offset = 0
|
offset = 0
|
||||||
data = pdb.get_activitys(offset, itemType='Audio')
|
data = pdb.get_activitys(offset, itemType='Audio')
|
||||||
count = 0
|
|
||||||
re = None
|
re = None
|
||||||
itemMap = {}
|
itemMap = {}
|
||||||
itemList = {}
|
rowMap = {}
|
||||||
while len(data) > 0:
|
while len(data) > 0:
|
||||||
for d in data:
|
for d in data:
|
||||||
itemId = d['ItemId']
|
itemId = d['ItemId']
|
||||||
|
rowid = d['rowid']
|
||||||
item = ldb.get_item(itemId)
|
item = ldb.get_item(itemId)
|
||||||
if not item:
|
if not item:
|
||||||
re = icache.get(itemId)
|
re = icache.get(itemId)
|
||||||
if re:
|
if re and isinstance(re, str):
|
||||||
|
if re == 'no_track':
|
||||||
|
if itemId in itemMap:
|
||||||
|
rowMap[rowid] = itemId
|
||||||
|
else:
|
||||||
|
itemMap[itemId] = d
|
||||||
|
rowMap[rowid] = itemId
|
||||||
|
continue
|
||||||
|
re = None
|
||||||
|
elif re:
|
||||||
item = ldb.get_item(re['id'])
|
item = ldb.get_item(re['id'])
|
||||||
if item and isinstance(item, str):
|
if item and item['PresentationUniqueKey'] in itemMap:
|
||||||
if item == 'no_track':
|
rowMap[rowid] = item['PresentationUniqueKey']
|
||||||
if itemId in itemMap:
|
|
||||||
itemList[itemId].append(d)
|
|
||||||
else:
|
|
||||||
itemMap[itemId] = d
|
|
||||||
itemList[itemId] = [d]
|
|
||||||
continue
|
|
||||||
elif item and item['PresentationUniqueKey'] in itemMap:
|
|
||||||
itemList[item['PresentationUniqueKey']].append(d)
|
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
if itemId in itemMap:
|
if itemId in itemMap:
|
||||||
itemList[itemId].append(d)
|
rowMap[rowid] = itemId
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
if itemId in itemMap:
|
if itemId in itemMap:
|
||||||
itemList[itemId].append(d)
|
rowMap[rowid] = itemId
|
||||||
continue
|
continue
|
||||||
if not item:
|
if not item:
|
||||||
itemName = d['ItemName']
|
itemName = d['ItemName']
|
||||||
@@ -173,12 +178,52 @@ def generate_audio_report(pdb: PlaybackReportingDb, ldb: LibraryDb,
|
|||||||
if item:
|
if item:
|
||||||
itemId = item['PresentationUniqueKey']
|
itemId = item['PresentationUniqueKey']
|
||||||
itemMap[itemId] = item
|
itemMap[itemId] = item
|
||||||
itemList[itemId] = [d]
|
rowMap[rowid] = itemId
|
||||||
else:
|
else:
|
||||||
print(d)
|
|
||||||
itemMap[itemId] = d
|
itemMap[itemId] = d
|
||||||
itemList[itemId] = [d]
|
rowMap[rowid] = itemId
|
||||||
count += 1
|
|
||||||
offset += len(data)
|
offset += len(data)
|
||||||
data = pdb.get_activitys(offset, itemType='Audio')
|
data = pdb.get_activitys(offset, itemType='Audio')
|
||||||
print('Count', count)
|
return itemMap, rowMap
|
||||||
|
|
||||||
|
|
||||||
|
def generate_audio_report(pdb: PlaybackReportingDb, itemMap, rowMap,
|
||||||
|
output: str, userId: str = None,
|
||||||
|
startTime: float = None, endTime: float = None):
|
||||||
|
makedirs(output, exist_ok=True)
|
||||||
|
offset = 0
|
||||||
|
data = pdb.get_activitys(offset, itemType='Audio', userId=userId,
|
||||||
|
startTime=startTime, endTime=endTime)
|
||||||
|
with CSVFile(join(output, "history.csv")) as his:
|
||||||
|
his.write(_("Id"), _("Date"), _("Time"), _("Name"), _("Artists"), _("Album"), _("Album artists"), _("Original item id"), _("Item id"), _("Play Duration"), _("Record content"), _("Client name"), _("Device name"), _("Playback method")) # noqa: E501
|
||||||
|
while len(data) > 0:
|
||||||
|
for i in data:
|
||||||
|
rowid = i['rowid']
|
||||||
|
itemId = rowMap[rowid]
|
||||||
|
created = datetime.fromtimestamp(parse_time(i['DateCreated']),
|
||||||
|
None)
|
||||||
|
date = created.strftime("%Y-%m-%d")
|
||||||
|
time = created.strftime("%H:%M:%S.%f")
|
||||||
|
item = itemMap[itemId]
|
||||||
|
name = ''
|
||||||
|
artists = ''
|
||||||
|
album = ''
|
||||||
|
album_artists = ''
|
||||||
|
original_item_id = i['ItemId']
|
||||||
|
play_duration = i['PlayDuration']
|
||||||
|
if 'type' in item:
|
||||||
|
name = item['Name']
|
||||||
|
artists = item['Artists']
|
||||||
|
album = item['Album']
|
||||||
|
album_artists = item['AlbumArtists']
|
||||||
|
else:
|
||||||
|
it = ITEMNAME_PATTERN.match(item['ItemName']).groupdict()
|
||||||
|
name = it['track']
|
||||||
|
if it['album'] != 'Not Known':
|
||||||
|
album = it['album']
|
||||||
|
if it['album_artist'] != "Not Known":
|
||||||
|
album_artists = it['album_artist']
|
||||||
|
his.write(rowid, date, time, name, artists, album, album_artists, original_item_id, itemId, play_duration, i['ItemName'], i['ClientName'], i['DeviceName'], i['PlaybackMethod']) # noqa: E501
|
||||||
|
offset += len(data)
|
||||||
|
data = pdb.get_activitys(offset, itemType='Audio', userId=userId,
|
||||||
|
startTime=startTime, endTime=endTime)
|
||||||
|
|||||||
@@ -57,6 +57,17 @@ class Config:
|
|||||||
if 'jellyfin_data_dir' in self._data and self._data['jellyfin_data_dir']: # noqa: E501
|
if 'jellyfin_data_dir' in self._data and self._data['jellyfin_data_dir']: # noqa: E501
|
||||||
return self._data['jellyfin_data_dir']
|
return self._data['jellyfin_data_dir']
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def jellyfin_db(self) -> str:
|
||||||
|
if self._args and self._args.jellyfin_db:
|
||||||
|
return self._args.jellyfin_db
|
||||||
|
if 'jellyfin_db' in self._data and self._data['jellyfin_db']:
|
||||||
|
return self._data['jellyfin_db']
|
||||||
|
d = self.jellyfin_data_dir
|
||||||
|
if d:
|
||||||
|
return join(d, "jellyfin.db")
|
||||||
|
raise ValueError(_('%s not set.') % ('jellyfin_db'))
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def output_dir(self) -> str:
|
def output_dir(self) -> str:
|
||||||
if self._args and self._args.output_dir:
|
if self._args and self._args.output_dir:
|
||||||
|
|||||||
109
jellyfinstats/csv.py
Normal file
109
jellyfinstats/csv.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
from enum import Enum
|
||||||
|
from typing import IO, List
|
||||||
|
|
||||||
|
|
||||||
|
UTF8_BOM = b'\xef\xbb\xbf'
|
||||||
|
|
||||||
|
|
||||||
|
def escapeField(s: str) -> str:
|
||||||
|
if s.find('\r\n') > -1 or s.find(',') > -1 or s.find('"') > -1:
|
||||||
|
return '"' + s.replace('"', '""') + '"'
|
||||||
|
else:
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def readField(f: IO[str]) -> List[str]:
|
||||||
|
t = f.readline()
|
||||||
|
le = len(t)
|
||||||
|
if le == 0:
|
||||||
|
return None
|
||||||
|
i = 0
|
||||||
|
r = []
|
||||||
|
s = ''
|
||||||
|
e = False
|
||||||
|
a = False
|
||||||
|
while i < le:
|
||||||
|
n = t[i]
|
||||||
|
if n == '"' and s == '':
|
||||||
|
e = True
|
||||||
|
elif n == '"' and e:
|
||||||
|
e = False
|
||||||
|
a = True
|
||||||
|
elif n == '"' and a:
|
||||||
|
e = True
|
||||||
|
a = False
|
||||||
|
s += '"'
|
||||||
|
elif not e and n == ',':
|
||||||
|
r.append(s)
|
||||||
|
s = ''
|
||||||
|
a = False
|
||||||
|
else:
|
||||||
|
a = False
|
||||||
|
s += n
|
||||||
|
i += 1
|
||||||
|
if e and i == le:
|
||||||
|
t += f.readline()
|
||||||
|
le = len(t)
|
||||||
|
if s != '':
|
||||||
|
r.append(s)
|
||||||
|
if r[-1][-1] == '\n':
|
||||||
|
r[-1] = r[-1][:-1]
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def writeField(f: IO[bytes], *k):
|
||||||
|
a = False
|
||||||
|
for i in k:
|
||||||
|
if a:
|
||||||
|
f.write(b',')
|
||||||
|
else:
|
||||||
|
a = True
|
||||||
|
if isinstance(i, bool):
|
||||||
|
i = str(int(i))
|
||||||
|
elif isinstance(i, Enum):
|
||||||
|
i = str(i.value)
|
||||||
|
elif not isinstance(i, str):
|
||||||
|
i = str(i)
|
||||||
|
f.write(escapeField(i).encode())
|
||||||
|
f.write(b'\r\n')
|
||||||
|
|
||||||
|
|
||||||
|
class OpenMode(Enum):
|
||||||
|
Read = 1
|
||||||
|
Write = 2
|
||||||
|
|
||||||
|
|
||||||
|
class CSVFile:
|
||||||
|
def __init__(self, path: str, mode: OpenMode = OpenMode.Write):
|
||||||
|
mod = 'rb' if mode == OpenMode.Read else 'wb'
|
||||||
|
self._mode = mode
|
||||||
|
self._f = open(path, mod)
|
||||||
|
if mode == OpenMode.Read:
|
||||||
|
bom = self._f.read(3)
|
||||||
|
if bom != UTF8_BOM:
|
||||||
|
self._f.seek(0)
|
||||||
|
else:
|
||||||
|
self._f.write(UTF8_BOM)
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, tp, val, trace):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
self._f.close()
|
||||||
|
self._closed = True
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
if self._mode != OpenMode.Read:
|
||||||
|
raise ValueError("Stream is not readable")
|
||||||
|
return readField(self._f)
|
||||||
|
|
||||||
|
def write(self, *k):
|
||||||
|
if self._mode != OpenMode.Write:
|
||||||
|
raise ValueError("Stream is not writable")
|
||||||
|
writeField(self._f, *k)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
|
from .utils import convert_uid, format_time
|
||||||
|
|
||||||
|
|
||||||
class PlaybackReportingDb:
|
class PlaybackReportingDb:
|
||||||
@@ -19,15 +20,38 @@ class PlaybackReportingDb:
|
|||||||
self._closed = True
|
self._closed = True
|
||||||
|
|
||||||
def get_activitys(self, offset: int = 0, limit: int = 100,
|
def get_activitys(self, offset: int = 0, limit: int = 100,
|
||||||
itemType: str = None):
|
itemType: str = None, userId: str = None,
|
||||||
|
startTime: float = None, endTime: float = None):
|
||||||
|
where_sqls = []
|
||||||
|
where_sql = ''
|
||||||
|
args = []
|
||||||
|
if itemType is not None:
|
||||||
|
where_sqls.append('ItemType = ?')
|
||||||
|
args.append(itemType)
|
||||||
|
if userId is not None:
|
||||||
|
where_sqls.append('UserId = ?')
|
||||||
|
args.append(userId)
|
||||||
|
if startTime is not None:
|
||||||
|
where_sqls.append('DateCreated >= ?')
|
||||||
|
args.append(format_time(startTime))
|
||||||
|
if endTime is not None:
|
||||||
|
where_sqls.append('DateCreated <= ?')
|
||||||
|
args.append(format_time(endTime))
|
||||||
|
if len(where_sqls):
|
||||||
|
where_sql = ' WHERE ' + " AND ".join(where_sqls)
|
||||||
|
args.append(limit)
|
||||||
|
args.append(offset)
|
||||||
|
cur = self._db.execute(f"SELECT ROWID, * FROM PlaybackActivity{where_sql} LIMIT ? OFFSET ?;", args) # noqa: E501
|
||||||
|
cur.row_factory = sqlite3.Row
|
||||||
|
return [dict(i) for i in cur.fetchall()]
|
||||||
|
|
||||||
|
def get_users(self, itemType: str = None):
|
||||||
where_sql = ''
|
where_sql = ''
|
||||||
args = []
|
args = []
|
||||||
if itemType is not None:
|
if itemType is not None:
|
||||||
where_sql = ' WHERE ItemType = ?'
|
where_sql = ' WHERE ItemType = ?'
|
||||||
args.append(itemType)
|
args.append(itemType)
|
||||||
args.append(limit)
|
cur = self._db.execute(f"SELECT UserId, min(DateCreated) AS MinDate, max(DateCreated) AS MaxDate FROM PlaybackActivity{where_sql} GROUP BY UserId;", args) # noqa: E501
|
||||||
args.append(offset)
|
|
||||||
cur = self._db.execute(f"SELECT * FROM PlaybackActivity{where_sql} LIMIT ? OFFSET ?;", args) # noqa: E501
|
|
||||||
cur.row_factory = sqlite3.Row
|
cur.row_factory = sqlite3.Row
|
||||||
return [dict(i) for i in cur.fetchall()]
|
return [dict(i) for i in cur.fetchall()]
|
||||||
|
|
||||||
@@ -72,3 +96,29 @@ class LibraryDb:
|
|||||||
cur = self._db.execute(f"SELECT * FROM TypedBaseItems WHERE type = ?{where_sql};", args) # noqa: E501
|
cur = self._db.execute(f"SELECT * FROM TypedBaseItems WHERE type = ?{where_sql};", args) # noqa: E501
|
||||||
cur.row_factory = sqlite3.Row
|
cur.row_factory = sqlite3.Row
|
||||||
return [dict(i) for i in cur.fetchall()]
|
return [dict(i) for i in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinDb:
|
||||||
|
def __init__(self, fn: str):
|
||||||
|
self._db = sqlite3.connect(fn)
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, tp, val, trace):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
self._db.close()
|
||||||
|
self._closed = True
|
||||||
|
|
||||||
|
def get_user(self, userId: str):
|
||||||
|
if len(userId) == 32:
|
||||||
|
userId = convert_uid(userId)
|
||||||
|
cur = self._db.execute("SELECT * FROM Users WHERE Id = ?;", [userId])
|
||||||
|
cur.row_factory = sqlite3.Row
|
||||||
|
re = cur.fetchone()
|
||||||
|
return dict(re) if re is not None else None
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
from math import ceil
|
from math import ceil
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from re import compile
|
||||||
from . import _
|
from . import _
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
|
||||||
|
|
||||||
|
DATETIME_RE = compile(r'(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}).(\d{0,6})') # noqa: E501
|
||||||
|
|
||||||
|
|
||||||
def ask_choice(cfg: Config, choices: list, prompt=_("Please choose: "),
|
def ask_choice(cfg: Config, choices: list, prompt=_("Please choose: "),
|
||||||
fn=None, extra=None):
|
fn=None, extra=None):
|
||||||
if extra:
|
if extra:
|
||||||
@@ -66,3 +71,19 @@ def ask_choice(cfg: Config, choices: list, prompt=_("Please choose: "),
|
|||||||
if index < 0 or index >= count:
|
if index < 0 or index >= count:
|
||||||
continue
|
continue
|
||||||
return choices[index]
|
return choices[index]
|
||||||
|
|
||||||
|
|
||||||
|
def format_time(time: float | None = None, tz=timezone.utc) -> str:
|
||||||
|
d = datetime.fromtimestamp(time, tz=tz)
|
||||||
|
return d.strftime('%Y-%m-%d %H:%M:%S.%f')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time(time: str) -> float:
|
||||||
|
re = DATETIME_RE.match(time)
|
||||||
|
t = datetime(int(re[1]), int(re[2]), int(re[3]), int(re[4]), int(re[5]), int(re[6]), int(re[7].ljust(6, '0')), timezone.utc) # noqa: E501
|
||||||
|
return t.timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
def convert_uid(uid: str) -> str:
|
||||||
|
t = uid.upper()
|
||||||
|
return f"{t[:8]}-{t[8:12]}-{t[12:16]}-{t[16:20]}-{t[20:]}"
|
||||||
|
|||||||
Reference in New Issue
Block a user