From 4f133e976df3e6021137880de86d574f2b9bb871 Mon Sep 17 00:00:00 2001 From: lifegpc Date: Wed, 15 May 2024 23:15:24 +0800 Subject: [PATCH] Update code --- jellyfinstats/__main__.py | 18 +++++-- jellyfinstats/audio.py | 91 +++++++++++++++++++++++-------- jellyfinstats/config.py | 11 ++++ jellyfinstats/csv.py | 109 ++++++++++++++++++++++++++++++++++++++ jellyfinstats/db.py | 58 ++++++++++++++++++-- jellyfinstats/utils.py | 21 ++++++++ 6 files changed, 278 insertions(+), 30 deletions(-) create mode 100644 jellyfinstats/csv.py diff --git a/jellyfinstats/__main__.py b/jellyfinstats/__main__.py index 86f9009..ce9c910 100644 --- a/jellyfinstats/__main__.py +++ b/jellyfinstats/__main__.py @@ -1,9 +1,10 @@ from argparse import ArgumentParser +from os.path import join from . import _ -from .audio import generate_audio_report +from .audio import prepare_audio_map, generate_audio_report from .cache import IdRelativeCache from .config import Config -from .db import PlaybackReportingDb, LibraryDb +from .db import PlaybackReportingDb, LibraryDb, JellyfinDb 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("--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("--jellyfin-db", help=_("The path to jellyfin.db")) arg = p.parse_intermixed_args() cfg = Config(arg.config, arg) with PlaybackReportingDb(cfg.playback_reporting_db) as pdb: with LibraryDb(cfg.library_db) as ldb: 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) diff --git a/jellyfinstats/audio.py b/jellyfinstats/audio.py index 6fee894..2f57632 100644 --- a/jellyfinstats/audio.py +++ b/jellyfinstats/audio.py @@ -1,9 +1,13 @@ from . import _ from .cache import IdRelativeCache from .config import Config +from .csv import CSVFile 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 os import makedirs +from os.path import join ITEMNAME_PATTERN = compile(r'(?P.*) - (?P.*) \((?P.*)\)') # noqa: E501 @@ -108,40 +112,41 @@ class AudioSelector: return self.re -def generate_audio_report(pdb: PlaybackReportingDb, ldb: LibraryDb, - icache: IdRelativeCache, cfg: Config): +def prepare_audio_map(pdb: PlaybackReportingDb, ldb: LibraryDb, + icache: IdRelativeCache, cfg: Config): offset = 0 data = pdb.get_activitys(offset, itemType='Audio') - count = 0 re = None itemMap = {} - itemList = {} + rowMap = {} while len(data) > 0: for d in data: itemId = d['ItemId'] + rowid = d['rowid'] item = ldb.get_item(itemId) if not item: 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']) - if item and isinstance(item, str): - if item == 'no_track': - 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) + if item and item['PresentationUniqueKey'] in itemMap: + rowMap[rowid] = item['PresentationUniqueKey'] continue else: if itemId in itemMap: - itemList[itemId].append(d) + rowMap[rowid] = itemId continue else: if itemId in itemMap: - itemList[itemId].append(d) + rowMap[rowid] = itemId continue if not item: itemName = d['ItemName'] @@ -173,12 +178,52 @@ def generate_audio_report(pdb: PlaybackReportingDb, ldb: LibraryDb, if item: itemId = item['PresentationUniqueKey'] itemMap[itemId] = item - itemList[itemId] = [d] + rowMap[rowid] = itemId else: - print(d) itemMap[itemId] = d - itemList[itemId] = [d] - count += 1 + rowMap[rowid] = itemId offset += len(data) 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) diff --git a/jellyfinstats/config.py b/jellyfinstats/config.py index 05ce2c7..bae06ae 100644 --- a/jellyfinstats/config.py +++ b/jellyfinstats/config.py @@ -57,6 +57,17 @@ class Config: if 'jellyfin_data_dir' in self._data and self._data['jellyfin_data_dir']: # noqa: E501 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 def output_dir(self) -> str: if self._args and self._args.output_dir: diff --git a/jellyfinstats/csv.py b/jellyfinstats/csv.py new file mode 100644 index 0000000..d9ff307 --- /dev/null +++ b/jellyfinstats/csv.py @@ -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) diff --git a/jellyfinstats/db.py b/jellyfinstats/db.py index 646524a..81b33f5 100644 --- a/jellyfinstats/db.py +++ b/jellyfinstats/db.py @@ -1,4 +1,5 @@ import sqlite3 +from .utils import convert_uid, format_time class PlaybackReportingDb: @@ -19,15 +20,38 @@ class PlaybackReportingDb: self._closed = True 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 = '' args = [] if itemType is not None: where_sql = ' WHERE ItemType = ?' args.append(itemType) - args.append(limit) - args.append(offset) - cur = self._db.execute(f"SELECT * FROM PlaybackActivity{where_sql} LIMIT ? OFFSET ?;", args) # noqa: E501 + 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 cur.row_factory = sqlite3.Row 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.row_factory = sqlite3.Row 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 diff --git a/jellyfinstats/utils.py b/jellyfinstats/utils.py index 6450dc0..3bd090a 100644 --- a/jellyfinstats/utils.py +++ b/jellyfinstats/utils.py @@ -1,8 +1,13 @@ from math import ceil +from datetime import datetime, timezone +from re import compile from . import _ 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: "), fn=None, extra=None): if extra: @@ -66,3 +71,19 @@ def ask_choice(cfg: Config, choices: list, prompt=_("Please choose: "), if index < 0 or index >= count: continue 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:]}"