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

View File

@@ -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<album_artist>.*) - (?P<track>.*) \((?P<album>.*)\)') # 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)

View File

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

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

View File

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