Update code

This commit is contained in:
2024-05-15 23:15:24 +08:00
parent df84f07bce
commit 4f133e976d
6 changed files with 278 additions and 30 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
View 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)

View File

@@ -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

View File

@@ -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:]}"