Update code

This commit is contained in:
2024-05-15 14:03:28 +08:00
parent 386cee66ec
commit df84f07bce
12 changed files with 636 additions and 21 deletions

3
.gitignore vendored
View File

@@ -160,3 +160,6 @@ cython_debug/
#.idea/
config.yaml
*.mo
*.po~
output/

1
compile_i18n.sh Executable file
View File

@@ -0,0 +1 @@
msgmerge --for-msgfmt jellyfinstats/language/zh_CN/LC_MESSAGES/jellyfinStats.po jellyfinstats/language/jellyfinStats.po | msgfmt -o jellyfinstats/language/zh_CN/LC_MESSAGES/jellyfinStats.mo -

View File

@@ -0,0 +1,10 @@
from gettext import bindtextdomain, gettext as _, textdomain # noqa: F401
from os.path import join
try:
bindtextdomain('jellyfinStats', join(__path__[0], 'language'))
textdomain('jellyfinStats')
except Exception:
from traceback import print_exc
print_exc()

View File

@@ -1,16 +1,21 @@
from argparse import ArgumentParser
from . import _
from .audio import generate_audio_report
from .cache import IdRelativeCache
from .config import Config
from .db import PlaybackReportingDb, LibraryDb
p = ArgumentParser()
p.add_argument("-c", "--config", help="The path to config file.", default="config.yaml") # noqa: E501
p.add_argument("--playback-reporting-db", help="The path to playback_reporting.db") # noqa: E501
p.add_argument("--library-db", help="The path to library.db")
p.add_argument("--jellyfin-data-dir", help="The path to data directory.")
p = ArgumentParser(prog="jellyfinstats")
p.add_argument("-c", "--config", help=_("The path to config file."), default="config.yaml") # noqa: E501
p.add_argument("--playback-reporting-db", help=_("The path to playback_reporting.db")) # noqa: E501
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
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:
generate_audio_report(pdb, ldb)
with IdRelativeCache(cfg.output_dir) as icache:
generate_audio_report(pdb, ldb, icache, cfg)

View File

@@ -1,23 +1,152 @@
from . import _
from .cache import IdRelativeCache
from .config import Config
from .db import PlaybackReportingDb, LibraryDb
from .utils import ask_choice
from re import compile
ITEMNAME_PATTERN = compile(r'(?P<album_artist>.*) - (?P<track>.*) \((?P<album>.*)\)') # noqa: E501
def generate_audio_report(pdb: PlaybackReportingDb, ldb: LibraryDb):
def print_item(item):
s = item['Name']
if item['Album']:
s += "\n" + _("Album: ") + item['Album']
if item['Artists']:
s += "\n" + _("Artists: ") + item['Artists']
if item['AlbumArtists']:
s += "\n" + _("Album artists: ") + item['AlbumArtists']
return s
class AudioSelector:
def __init__(self, cfg: Config, origin, ldb: LibraryDb, choices=None):
self.cfg = cfg
self.origin = origin
self.ldb = ldb
self.choices = choices if choices and len(choices) else None
self.re = None
self.fns = []
def print_original(self):
print(_("Original item: ") + self.origin['track'])
if self.origin['album']:
print(_("Album: ") + self.origin['album'])
if self.origin['album_artist']:
print(_("Album artists: ") + self.origin['album_artist'])
def choose_in_choices(self):
item = ask_choice(self.cfg, self.choices, _("Please choose audio item:"), print_item, (("x", _("No item"), "none"), ("o", _("Choose other items"), "other"),)) # noqa: E501
self.re = item
if item == "none":
self.re = None
elif item == "other":
self.fns.append(self.choose_others)
def choose_others(self):
act = [("i", _("Input item id"), "id"), ("t", _("Input track name"), "track"), ("a", _("Input album name"), "album"), ("x", _("No item"), "none")] # noqa: E501
if self.choices:
act.append(("o", _("Choose in given choices"), "choose"))
re = ask_choice(self.cfg, [], _("Please choose action: "), extra=act)
if re == "choose":
self.fns.append(self.choose_in_choices)
elif re == "id":
self.fns.append(self.input_id)
elif re == "track":
self.fns.append(self.input_track)
elif re == "album":
self.fns.append(self.input_album)
elif re == "none":
self.re = None
def input_id(self):
id = input(_("Please input item id:"))
item = self.ldb.get_item(id)
if item and item['type'] == 'MediaBrowser.Controller.Entities.Audio.Audio': # noqa: E501
self.re = item
else:
print(_("Item not found."))
self.fns.append(self.choose_others)
def handle_items(self, items):
if len(items):
act = [("b", _("Back"), "back"), ("x", _("No item"), "none")]
if self.choices:
act.append(("o", _("Choose in given choices"), "choose"))
self.re = ask_choice(self.cfg, items, _("Please choose audio item:"), print_item, act) # noqa: E501
if self.re == "back":
self.fns.append(self.choose_others)
elif self.re == "choose":
self.fns.append(self.choose_in_choices)
elif self.re == "none":
self.re = None
else:
print(_("Items not found."))
self.fns.append(self.choose_others)
def input_track(self):
track = input(_("Please input track name:"))
items = self.ldb.get_audios(track)
self.handle_items(items)
def input_album(self):
album = input(_("Please input album name:"))
items = self.ldb.get_audios(album=album)
self.handle_items(items)
def ask(self):
self.print_original()
if self.choices:
self.fns.append(self.choose_in_choices)
else:
self.fns.append(self.choose_others)
while True:
if len(self.fns) == 0:
break
self.fns.pop(0)()
return self.re
def generate_audio_report(pdb: PlaybackReportingDb, ldb: LibraryDb,
icache: IdRelativeCache, cfg: Config):
offset = 0
data = pdb.get_activitys(offset, itemType='Audio')
count = 0
re = None
itemMap = {}
itemList = {}
while len(data) > 0:
for d in data:
itemId = d['ItemId']
item = ldb.get_item(itemId)
if item:
pass
if not item:
re = icache.get(itemId)
if 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)
continue
else:
if itemId in itemMap:
itemList[itemId].append(d)
continue
else:
if itemId in itemMap:
itemList[itemId].append(d)
continue
if not item:
itemName = d['ItemName']
re = ITEMNAME_PATTERN.match(itemName)
if re is None:
re = ITEMNAME_PATTERN.match(itemName)
if re is None:
raise ValueError(f"Failed to parse ItemName: {itemName}")
re = re.groupdict()
@@ -27,13 +156,29 @@ def generate_audio_report(pdb: PlaybackReportingDb, ldb: LibraryDb):
re['album'] = None
items = ldb.get_audios(re['track'], re['album'])
if len(items) == 1:
pass
newId = items[0]['PresentationUniqueKey']
icache.set(itemId, newId, {'album': items[0]['Album'], 'track': items[0]['Name'], 'album_artist': items[0]['AlbumArtists'], 'original': re}) # noqa: E501
item = items[0]
else:
if len(items):
print(items)
if not len(items):
items = ldb.get_audios(re['track'])
if not len(items) and re['album']:
items = ldb.get_audios(album=re['album'])
item = AudioSelector(cfg, re, ldb, items).ask()
if item:
newId = item['PresentationUniqueKey']
icache.set(itemId, newId, {'album': item['Album'], 'track': item['Name'], 'album_artist': item['AlbumArtists'], 'original': re}) # noqa: E501
else:
print(re)
count += 1
icache.set_value(itemId, 'no_track')
if item:
itemId = item['PresentationUniqueKey']
itemMap[itemId] = item
itemList[itemId] = [d]
else:
print(d)
itemMap[itemId] = d
itemList[itemId] = [d]
count += 1
offset += len(data)
data = pdb.get_activitys(offset, itemType='Audio')
print('Count', count)

54
jellyfinstats/cache.py Normal file
View File

@@ -0,0 +1,54 @@
from os import makedirs
from os.path import exists, join
from typing import Dict, Any
from yaml import dump as dumpyaml, load as loadyaml
try:
from yaml import CSafeDumper as SafeDumper, CSafeLoader as SafeLoader
except ImportError:
from yaml import SafeDumper, SafeLoader
from . import _
class IdRelativeCache:
def __init__(self, output_dir: str):
makedirs(output_dir, exist_ok=True)
self._path = join(output_dir, 'id_relative_cache.yaml')
self._data = {}
if exists(self._path):
try:
with open(self._path, "r", encoding="UTF-8") as f:
data = loadyaml(f, SafeLoader)
version = data['version']
if version > 1:
t = _("Unsupported version: ")
raise NotImplementedError(f'{t}{version}')
self._data = data['data']
except Exception:
from traceback import print_exc
print_exc()
print(_("Failed to load cache."))
self._closed = False
def __enter__(self):
return self
def __exit__(self, tp, val, trace):
self.close()
def close(self):
if self._closed:
return
with open(self._path, "w", encoding='UTF-8') as f:
dumpyaml({'version': 1, 'data': self._data}, f, SafeDumper,
allow_unicode=True)
self._closed = True
def get(self, oldId: str):
return self._data[oldId] if oldId in self._data else None
def set(self, oldId: str, newId: str, data: Dict[str, Any]):
self._data[oldId] = {k: data[k] for k in data}
self._data[oldId]['id'] = newId
def set_value(self, oldId: str, value):
self._data[oldId] = value

View File

@@ -9,14 +9,25 @@ try:
from yaml import CSafeLoader as SafeLoader
except ImportError:
from yaml import SafeLoader
from . import _
class Config:
def __init__(self, path: str, args: Namespace = None):
with open(path, encoding="UTF-8") as f:
self._data = loadyaml(f, Loader=SafeLoader)
if self._data is None:
self._data = {}
self._args = args
@cached_property
def ask_page_size(self) -> int:
if self._args and self._args.ask_page_size is not None:
return self._args.ask_page_size
if 'ask_page_size' in self._data and isinstance(self._data['ask_page_size'], int): # noqa: E501
return self._data['ask_page_size']
return 10
@cached_property
def playback_reporting_db(self) -> str:
if self._args and self._args.playback_reporting_db:
@@ -26,7 +37,7 @@ class Config:
d = self.jellyfin_data_dir
if d:
return join(d, "playback_reporting.db")
raise ValueError('playback_reporting.db not set.')
raise ValueError(_('%s not set.') % ('playback_reporting_db'))
@cached_property
def library_db(self) -> str:
@@ -37,7 +48,7 @@ class Config:
d = self.jellyfin_data_dir
if d:
return join(d, "library.db")
raise ValueError('library.db not set.')
raise ValueError(_('%s not set.') % ('library_db'))
@cached_property
def jellyfin_data_dir(self) -> str | None:
@@ -45,3 +56,11 @@ class Config:
return self._args.jellyfin_data_dir
if 'jellyfin_data_dir' in self._data and self._data['jellyfin_data_dir']: # noqa: E501
return self._data['jellyfin_data_dir']
@cached_property
def output_dir(self) -> str:
if self._args and self._args.output_dir:
return self._args.output_dir
if 'output_dir' in self._data and self._data['output_dir']:
return self._data['output_dir']
return 'output'

View File

@@ -60,12 +60,15 @@ class LibraryDb:
re = cur.fetchone()
return dict(re) if re is not None else None
def get_audios(self, track: str, album: str = None):
args = ['Audio', track]
def get_audios(self, track: str = None, album: str = None):
args = ['MediaBrowser.Controller.Entities.Audio.Audio']
where_sql = ''
if track is not None:
where_sql += ' AND Name = ?'
args.append(track)
if album is not None:
where_sql = ' AND Album = ?'
where_sql += ' AND Album = ?'
args.append(album)
cur = self._db.execute(f"SELECT * FROM TypedBaseItems WHERE MediaType = ? AND Name = ?{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
return [dict(i) for i in cur.fetchall()]

View File

@@ -0,0 +1,152 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR lifegpc
# This file is distributed under the same license as the jellyfinStats package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: jellyfinStats 1.0\n"
"Report-Msgid-Bugs-To: [email protected]\n"
"POT-Creation-Date: 2024-05-15 13:56+0800\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <[email protected]>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: __main__.py:10
msgid "The path to config file."
msgstr ""
#: __main__.py:11
msgid "The path to playback_reporting.db"
msgstr ""
#: __main__.py:12
msgid "The path to library.db"
msgstr ""
#: __main__.py:13
msgid "The path to jellyfin data directory."
msgstr ""
#: __main__.py:14
msgid "The directory for output files."
msgstr ""
#: __main__.py:15
msgid "Specify maximum items to display in one page."
msgstr ""
#: audio.py:15 audio.py:35
msgid "Album: "
msgstr ""
#: audio.py:17
msgid "Artists: "
msgstr ""
#: audio.py:19 audio.py:37
msgid "Album artists: "
msgstr ""
#: audio.py:33
msgid "Original item: "
msgstr ""
#: audio.py:40 audio.py:77
msgid "Please choose audio item:"
msgstr ""
#: audio.py:40 audio.py:48 audio.py:74
msgid "No item"
msgstr ""
#: audio.py:40
msgid "Choose other items"
msgstr ""
#: audio.py:48
msgid "Input item id"
msgstr ""
#: audio.py:48
msgid "Input track name"
msgstr ""
#: audio.py:48
msgid "Input album name"
msgstr ""
#: audio.py:50 audio.py:76
msgid "Choose in given choices"
msgstr ""
#: audio.py:51
msgid "Please choose action: "
msgstr ""
#: audio.py:64
msgid "Please input item id:"
msgstr ""
#: audio.py:69
msgid "Item not found."
msgstr ""
#: audio.py:74
msgid "Back"
msgstr ""
#: audio.py:85
msgid "Items not found."
msgstr ""
#: audio.py:89
msgid "Please input track name:"
msgstr ""
#: audio.py:94
msgid "Please input album name:"
msgstr ""
#: cache.py:23
msgid "Unsupported version: "
msgstr ""
#: cache.py:29
msgid "Failed to load cache."
msgstr ""
#: config.py:40 config.py:51
#, python-format
msgid "%s not set."
msgstr ""
#: utils.py:6
msgid "Please choose: "
msgstr ""
#: utils.py:23
#, python-format
msgid "Page %i/%i"
msgstr ""
#: utils.py:31
msgid "First page"
msgstr ""
#: utils.py:33
msgid "Previous page"
msgstr ""
#: utils.py:36
msgid "Next page"
msgstr ""
#: utils.py:38
msgid "Last page"
msgstr ""

View File

@@ -0,0 +1,151 @@
# Chinese translations for jellyfinStats package.
# Copyright (C) 2024 lifegpc
# This file is distributed under the same license as the jellyfinStats package.
# mhy <[email protected]>, 2024.
#
msgid ""
msgstr ""
"Project-Id-Version: jellyfinStats 1.0\n"
"Report-Msgid-Bugs-To: [email protected]\n"
"POT-Creation-Date: 2024-05-15 13:56+0800\n"
"PO-Revision-Date: 2024-05-15 10:20+0800\n"
"Last-Translator: mhy <[email protected]>\n"
"Language-Team: Chinese (simplified) <[email protected]>\n"
"Language: zh_CN\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: __main__.py:10
msgid "The path to config file."
msgstr "配置文件位置。"
#: __main__.py:11
msgid "The path to playback_reporting.db"
msgstr "playback_reporting.db 文件位置。"
#: __main__.py:12
msgid "The path to library.db"
msgstr "library.db 文件位置。"
#: __main__.py:13
msgid "The path to jellyfin data directory."
msgstr "Jellyfin 数据目录位置。"
#: __main__.py:14
msgid "The directory for output files."
msgstr "输出目录位置。"
#: __main__.py:15
msgid "Specify maximum items to display in one page."
msgstr "指定一页可以显示的最大条目数。"
#: audio.py:15 audio.py:35
msgid "Album: "
msgstr "专辑:"
#: audio.py:17
msgid "Artists: "
msgstr "艺术家:"
#: audio.py:19 audio.py:37
msgid "Album artists: "
msgstr "专辑艺术家:"
#: audio.py:33
msgid "Original item: "
msgstr "原项目:"
#: audio.py:40 audio.py:77
msgid "Please choose audio item:"
msgstr "请选择音乐项目:"
#: audio.py:40 audio.py:48 audio.py:74
msgid "No item"
msgstr "无对应项目"
#: audio.py:40
msgid "Choose other items"
msgstr "选择其他项目"
#: audio.py:48
msgid "Input item id"
msgstr "输入项目ID"
#: audio.py:48
msgid "Input track name"
msgstr "输入标题"
#: audio.py:48
msgid "Input album name"
msgstr "输入专辑名称"
#: audio.py:50 audio.py:76
msgid "Choose in given choices"
msgstr "从给出的选项中选择"
#: audio.py:51
msgid "Please choose action: "
msgstr "请选择方式:"
#: audio.py:64
msgid "Please input item id:"
msgstr "请输入项目ID:"
#: audio.py:69
msgid "Item not found."
msgstr "没有找到项目。"
#: audio.py:74
msgid "Back"
msgstr "返回"
#: audio.py:85
msgid "Items not found."
msgstr "没有找到项目。"
#: audio.py:89
msgid "Please input track name:"
msgstr "请输入标题:"
#: audio.py:94
msgid "Please input album name:"
msgstr "请输入专辑名称:"
#: cache.py:23
msgid "Unsupported version: "
msgstr "不支持的版本:"
#: cache.py:29
msgid "Failed to load cache."
msgstr "加载缓存失败。"
#: config.py:40 config.py:51
#, python-format
msgid "%s not set."
msgstr "%s 未设置。"
#: utils.py:6
msgid "Please choose: "
msgstr "请选择:"
#: utils.py:23
#, python-format
msgid "Page %i/%i"
msgstr "第%i/%i页"
#: utils.py:31
msgid "First page"
msgstr "第一页"
#: utils.py:33
msgid "Previous page"
msgstr "上一页"
#: utils.py:36
msgid "Next page"
msgstr "下一页"
#: utils.py:38
msgid "Last page"
msgstr "最后一页"

68
jellyfinstats/utils.py Normal file
View File

@@ -0,0 +1,68 @@
from math import ceil
from . import _
from .config import Config
def ask_choice(cfg: Config, choices: list, prompt=_("Please choose: "),
fn=None, extra=None):
if extra:
for n in extra:
if n[0] in ['f', 'p', 'n', 'l']:
raise ValueError(f'Internal action used: {n[0]}')
page_size = cfg.ask_page_size
if page_size <= 0:
page_size = 10
count = len(choices)
total_pages = ceil(count / page_size)
page = 1
def show_page():
nonlocal page
base = (page - 1) * page_size
if total_pages > 1:
print(_("Page %i/%i") % (page, total_pages))
for i in range(page_size):
index = base + i
if index >= count:
break
s = fn(choices[index]) if fn else choices[index]
print(f"{i}. {s}")
if page > 1:
fp = _("First page")
print(f'f. {fp}')
pp = _("Previous page")
print(f'p. {pp}')
if page < total_pages:
np = _("Next page")
print(f'n. {np}')
lp = _("Last page")
print(f'l. {lp}')
if extra is not None:
for t in extra:
print(f"{t[0]}. {t[1]}")
while True:
show_page()
s = input(prompt)
if s == "f":
page = 1
elif s == "p":
page = max(1, page - 1)
elif s == "n":
page = min(total_pages, page + 1)
elif s == "l":
page = total_pages
else:
if extra is not None:
for t in extra:
if t[0] == s:
return t[2]
try:
index = int(s)
except Exception:
continue
base = (page - 1) * page_size
index += base
if index < 0 or index >= count:
continue
return choices[index]

4
update_i18n.sh Executable file
View File

@@ -0,0 +1,4 @@
xgettext -D jellyfinstats __main__.py audio.py cache.py config.py utils.py \
-L python -d jellyfinStats -p jellyfinstats/language --copyright-holder 'lifegpc' --package-name 'jellyfinStats' --package-version '1.0' --msgid-bugs-address '[email protected]'
sed -i 's/charset=CHARSET/charset=UTF-8/g' jellyfinstats/language/jellyfinStats.po
msgmerge -U jellyfinstats/language/zh_CN/LC_MESSAGES/jellyfinStats.po jellyfinstats/language/jellyfinStats.po