├── src ├── __init__.py ├── logging │ ├── __init__.py │ ├── __pycache__ │ │ └── __init__.cpython-313.pyc │ ├── meson.build │ └── session_file_handler.py ├── views │ ├── __init__.py │ ├── meson.build │ └── db_update_view.py ├── pages │ ├── __init__.py │ ├── meson.build │ └── edit_episode_page.py ├── dialogs │ ├── __init__.py │ ├── meson.build │ └── add_tmdb_dialog.py ├── models │ ├── __init__.py │ ├── meson.build │ ├── language_model.py │ ├── search_result_model.py │ ├── episode_model.py │ └── season_model.py ├── providers │ ├── __init__.py │ ├── meson.build │ └── tmdb_provider.py ├── widgets │ ├── __init__.py │ ├── meson.build │ ├── background_indicator.py │ ├── theme_switcher.py │ ├── background_activity_row.py │ ├── poster_button.py │ ├── image_selector.py │ └── season_expander.py ├── css │ ├── style-dark.css │ └── style.css ├── ui │ ├── window.blp │ ├── about_dialog.blp │ ├── widgets │ │ ├── background_activity_row.blp │ │ ├── season_expander.blp │ │ ├── theme_switcher.blp │ │ ├── image_selector.blp │ │ ├── background_indicator.blp │ │ ├── episode_row.blp │ │ ├── search_result_row.blp │ │ └── poster_button.blp │ ├── dialogs │ │ ├── message_dialogs.blp │ │ ├── edit_season.blp │ │ ├── add_tmdb.blp │ │ └── add_manual.blp │ ├── gtk │ │ └── help-overlay.blp │ ├── views │ │ ├── first_run_view.blp │ │ ├── db_update_view.blp │ │ ├── content_view.blp │ │ └── main_view.blp │ ├── pages │ │ └── edit_episode_page.blp │ └── preferences.blp ├── ticketbooth.in ├── shared.py.in ├── meson.build ├── ticketbooth.gresource.xml.in └── background_queue.py ├── requirements.txt ├── data ├── blank_still.jpg ├── appstream │ ├── new1.png │ ├── new2.png │ ├── new3.png │ └── new4.png ├── blank_poster.jpg ├── icons │ ├── symbolic │ │ ├── plus-symbolic.svg │ │ ├── right-symbolic.svg │ │ ├── floppy-symbolic.svg │ │ ├── document-edit-symbolic.svg │ │ ├── check-plain-symbolic.svg │ │ ├── view-list-symbolic.svg │ │ ├── view-grid-symbolic.svg │ │ ├── warning-symbolic.svg │ │ ├── loupe-symbolic.svg │ │ ├── user-trash-symbolic.svg │ │ ├── network-transmit-receive-symbolic.svg │ │ ├── bell-outline-none-symbolic.svg │ │ ├── update-symbolic.svg │ │ ├── watchlist-symbolic.svg │ │ ├── star-large-symbolic.svg │ │ ├── hourglass-symbolic.svg │ │ ├── bell-outline-symbolic.svg │ │ ├── movies-symbolic.svg │ │ ├── check-round-outline-symbolic.svg │ │ └── series-symbolic.svg │ ├── meson.build │ └── hicolor │ │ └── symbolic │ │ └── apps │ │ ├── me.iepure.Ticketbooth-symbolic.svg │ │ └── me.iepure.Ticketbooth.Devel-symbolic.svg ├── me.iepure.Ticketbooth.desktop.in ├── meson.build ├── me.iepure.Ticketbooth.gschema.xml.in └── me.iepure.Ticketbooth.metainfo.xml.in ├── po ├── meson.build ├── LINGUAS └── POTFILES ├── meson_options.txt ├── install └── ticketbooth-run-script.in ├── .gitignore ├── .github ├── workflows │ └── build-x86.yaml └── ISSUE_TEMPLATE │ └── bug_report.md ├── me.iepure.Ticketbooth.Devel.json ├── CONTRIBUTING.md ├── meson.build ├── ticketbooth.doap ├── README.md ├── pypi-dependencies.json └── LICENSES └── CC0-1.0.txt /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/logging/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Pillow 2 | tmdbsimple 3 | -------------------------------------------------------------------------------- /data/blank_still.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aleiepure/ticketbooth/HEAD/data/blank_still.jpg -------------------------------------------------------------------------------- /data/appstream/new1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aleiepure/ticketbooth/HEAD/data/appstream/new1.png -------------------------------------------------------------------------------- /data/appstream/new2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aleiepure/ticketbooth/HEAD/data/appstream/new2.png -------------------------------------------------------------------------------- /data/appstream/new3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aleiepure/ticketbooth/HEAD/data/appstream/new3.png -------------------------------------------------------------------------------- /data/appstream/new4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aleiepure/ticketbooth/HEAD/data/appstream/new4.png -------------------------------------------------------------------------------- /data/blank_poster.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aleiepure/ticketbooth/HEAD/data/blank_poster.jpg -------------------------------------------------------------------------------- /src/pages/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | -------------------------------------------------------------------------------- /src/dialogs/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | -------------------------------------------------------------------------------- /src/models/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | -------------------------------------------------------------------------------- /src/providers/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | -------------------------------------------------------------------------------- /src/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | -------------------------------------------------------------------------------- /src/logging/__pycache__/__init__.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aleiepure/ticketbooth/HEAD/src/logging/__pycache__/__init__.cpython-313.pyc -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | i18n.gettext('ticketbooth', preset: 'glib') 6 | -------------------------------------------------------------------------------- /meson_options.txt: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | option('prerelease', type : 'boolean', value : true) 6 | -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- 1 | de 2 | fr 3 | it 4 | nb_NO 5 | es 6 | eu 7 | pt 8 | pt_BR 9 | pl 10 | uk 11 | hi 12 | bg 13 | tr 14 | nl 15 | fa 16 | ta 17 | fi 18 | et 19 | ia 20 | hu 21 | ar 22 | -------------------------------------------------------------------------------- /install/ticketbooth-run-script.in: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | # Copyright (C) 2023 Alessandro Iepure 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | TMDB_KEY="3cea7ff23278a75460ead4d6e2cef9af" exec /app/bin/ticketbooth-bin "$@" 7 | -------------------------------------------------------------------------------- /src/css/style-dark.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Alessandro Iepure 3 | * 4 | * SPDX-License-Identifier: GPL-3.0-or-later 5 | */ 6 | 7 | /* Dark mode CSS */ 8 | 9 | .chip { 10 | background-color: @dark_3; 11 | } 12 | -------------------------------------------------------------------------------- /data/icons/symbolic/plus-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/logging/meson.build: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 6 | loggingdir = join_paths(pkgdatadir, 'src/logging') 7 | 8 | sources = [ 9 | '__init__.py', 10 | 'session_file_handler.py', 11 | ] 12 | 13 | install_data(sources, install_dir: loggingdir) 14 | -------------------------------------------------------------------------------- /src/pages/meson.build: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 6 | pagesdir = join_paths(pkgdatadir, 'src/pages') 7 | 8 | sources = [ 9 | '__init__.py', 10 | 'details_page.py', 11 | 'edit_episode_page.py', 12 | ] 13 | 14 | install_data(sources, install_dir: pagesdir) 15 | -------------------------------------------------------------------------------- /src/providers/meson.build: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 6 | providersdir = join_paths(pkgdatadir, 'src/providers') 7 | 8 | sources = [ 9 | '__init__.py', 10 | 'tmdb_provider.py', 11 | 'local_provider.py', 12 | ] 13 | 14 | install_data(sources, install_dir: providersdir) 15 | -------------------------------------------------------------------------------- /data/me.iepure.Ticketbooth.desktop.in: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | [Desktop Entry] 6 | # TRANSLATORS: do not translate 7 | Name=@app_name@ 8 | Exec=ticketbooth 9 | Icon=@app_id@ 10 | Terminal=false 11 | Type=Application 12 | Categories=AudioVideo;Video;TV;Utility; 13 | Keywords=Movies;Films;Shows;TV;Series;Anime;Watch;List;Library; 14 | StartupNotify=true 15 | X-GNOME-UsesNotifications=true 16 | -------------------------------------------------------------------------------- /src/dialogs/meson.build: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 6 | dialogsdir = join_paths(pkgdatadir, 'src/dialogs') 7 | 8 | sources = [ 9 | '__init__.py', 10 | 'add_manual_dialog.py', 11 | 'add_tmdb_dialog.py', 12 | 'edit_season_dialog.py', 13 | ] 14 | 15 | install_data(sources, install_dir: dialogsdir) 16 | -------------------------------------------------------------------------------- /src/views/meson.build: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 6 | viewsdir = join_paths(pkgdatadir, 'src/views') 7 | 8 | sources = [ 9 | '__init__.py', 10 | 'main_view.py', 11 | 'first_run_view.py', 12 | 'db_update_view.py', 13 | 'content_view.py', 14 | ] 15 | 16 | install_data(sources, install_dir: viewsdir) 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | # IDE folders 6 | .flatpak/ 7 | .flatpak-builder/ 8 | .vscode/ 9 | 10 | # Build direcctories 11 | builddir/ 12 | _build/ 13 | 14 | # Configs 15 | .pre-commit-config.yaml 16 | .mypy_cache/ 17 | 18 | # Env files 19 | .env 20 | 21 | # Flatpak bundles 22 | me.iepure.Ticketbooth.flatpak 23 | me.iepure.Ticketbooth.Devel.flatpak 24 | 25 | /subprojects/blueprint-compiler 26 | -------------------------------------------------------------------------------- /data/icons/symbolic/right-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/ui/window.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | using Gtk 4.0; 6 | using Adw 1; 7 | 8 | template $TicketboothWindow: Adw.ApplicationWindow { 9 | default-width: 1024; 10 | default-height: 600; 11 | width-request: 360; 12 | height-request: 600; 13 | 14 | map => $_on_map(); 15 | close-request => $_on_close_request(); 16 | 17 | Adw.ViewStack _win_stack { 18 | vexpand: true; 19 | hexpand: true; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/models/meson.build: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 6 | modelsdir = join_paths(pkgdatadir, 'src/models') 7 | 8 | sources = [ 9 | '__init__.py', 10 | 'search_result_model.py', 11 | 'language_model.py', 12 | 'movie_model.py', 13 | 'episode_model.py', 14 | 'season_model.py', 15 | 'series_model.py', 16 | ] 17 | 18 | install_data(sources, install_dir: modelsdir) 19 | -------------------------------------------------------------------------------- /data/icons/meson.build: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | scalable_dir = join_paths('hicolor', 'scalable', 'apps') 6 | install_data( 7 | join_paths(scalable_dir, ('@0@.svg').format(app_id)), 8 | install_dir: join_paths(get_option('datadir'), 'icons', scalable_dir) 9 | ) 10 | 11 | symbolic_dir = join_paths('hicolor', 'symbolic', 'apps') 12 | install_data( 13 | join_paths(symbolic_dir, ('@0@-symbolic.svg').format(app_id)), 14 | install_dir: join_paths(get_option('datadir'), 'icons', symbolic_dir) 15 | ) 16 | -------------------------------------------------------------------------------- /data/icons/symbolic/floppy-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/widgets/meson.build: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 6 | widgetsdir = join_paths(pkgdatadir, 'src/widgets') 7 | 8 | sources = [ 9 | '__init__.py', 10 | 'theme_switcher.py', 11 | 'poster_button.py', 12 | 'search_result_row.py', 13 | 'episode_row.py', 14 | 'image_selector.py', 15 | 'season_expander.py', 16 | 'background_indicator.py', 17 | 'background_activity_row.py', 18 | ] 19 | 20 | install_data(sources, install_dir: widgetsdir) 21 | -------------------------------------------------------------------------------- /data/icons/symbolic/document-edit-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.github/workflows/build-x86.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main] 4 | pull_request: 5 | name: Build 6 | jobs: 7 | flatpak: 8 | name: "Build Flatpak" 9 | runs-on: ubuntu-latest 10 | container: 11 | image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-48 12 | options: --privileged 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: flatpak/flatpak-github-actions/flatpak-builder@v6 16 | with: 17 | bundle: ticketbooth-devel.flatpak 18 | manifest-path: me.iepure.Ticketbooth.Devel.json 19 | cache-key: flatpak-builder-${{ github.sha }} 20 | -------------------------------------------------------------------------------- /src/ui/about_dialog.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2022 - 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | using Gtk 4.0; 6 | using Adw 1; 7 | 8 | Adw.AboutDialog about_dialog { 9 | copyright: _("Copyright © 2023 - 2025 Alessandro Iepure\nSome icons are copyright of the GNOME Project"); 10 | comments: _("Keep track of your favorite shows"); 11 | website: "https://github.com/aleiepure/ticketbooth"; 12 | issue-url: "https://github.com/aleiepure/ticketbooth/issues"; 13 | license-type: gpl_3_0; 14 | developer-name: "Alessandro Iepure"; 15 | // TRANSLATORS: replace with your name (with optional email or website) 16 | translator-credits: _("translator-credits"); 17 | } -------------------------------------------------------------------------------- /data/icons/symbolic/check-plain-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/symbolic/view-list-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /data/icons/symbolic/view-grid-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /data/icons/symbolic/warning-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/ui/widgets/background_activity_row.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | using Gtk 4.0; 6 | using Adw 1; 7 | 8 | template $BackgroundActivityRow: Adw.Bin { 9 | tooltip-text: bind template.title; 10 | 11 | map => $_on_map(); 12 | 13 | Box { 14 | orientation: horizontal; 15 | spacing: 12; 16 | 17 | Image _icon { 18 | icon-name: 'check-plain'; 19 | pixel-size: 24; 20 | } 21 | 22 | Box { 23 | orientation: vertical; 24 | vexpand: true; 25 | valign: center; 26 | spacing: 6; 27 | 28 | Label { 29 | label: bind template.title; 30 | halign: start; 31 | max-width-chars: 30; 32 | ellipsize: end; 33 | } 34 | 35 | ProgressBar _progress_bar { 36 | hexpand: true; 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /data/icons/symbolic/loupe-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/ui/widgets/season_expander.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | using Gtk 4.0; 6 | using Adw 1; 7 | 8 | template $SeasonExpander: Adw.ExpanderRow { 9 | 10 | title: bind template.season_title; 11 | 12 | map => $_on_map(); 13 | 14 | [prefix] 15 | Picture _poster { 16 | height-request: 112; 17 | width-request: 75; 18 | content-fit: fill; 19 | margin-top: 12; 20 | margin-bottom: 12; 21 | 22 | styles ["still"] 23 | } 24 | 25 | [suffix] 26 | Box { 27 | orientation: horizontal; 28 | valign: center; 29 | 30 | styles ["linked"] 31 | 32 | Button _delete_btn { 33 | icon-name: "user-trash-symbolic"; 34 | 35 | clicked => $_on_delete_btn_clicked(); 36 | } 37 | 38 | Button _edit_btn { 39 | valign: center; 40 | child: Adw.ButtonContent { 41 | icon-name: "document-edit"; 42 | label: _("Edit"); 43 | }; 44 | 45 | clicked => $_on_edit_btn_clicked(); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the issue** 11 | A clear and concise description of what the problem is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Operating system (please complete the following information):** 27 | - Distro: 28 | - Version: 29 | - Desktop Environment: 30 | - Xorg/Wayland: 31 | 32 | **Additional context** 33 | - [ ] I am aware that this application uses content from third parties and I am not reporting issues about their data (wrong or missing data and/or images). If the latter is the case, please report directly to them. 34 | 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /src/models/language_model.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from gi.repository import GObject 6 | 7 | 8 | class LanguageModel(GObject.GObject): 9 | """ 10 | This class represents a language object stored in the db. 11 | 12 | Properties: 13 | iso_name (str): ISO_639_1 code 14 | name (str): localized or English name 15 | 16 | Methods: 17 | None 18 | 19 | Signals: 20 | None 21 | """ 22 | 23 | __gtype_name__ = 'LanguageModel' 24 | 25 | iso_name = GObject.Property(type=str, default='') 26 | name = GObject.Property(type=str, default='') 27 | 28 | def __init__(self, d=None, t=None): 29 | super().__init__() 30 | 31 | if d is not None: 32 | self.iso_name = d['iso_639_1'] 33 | 34 | if d['name']: 35 | self.name = d['name'] 36 | else: 37 | self.name = d['english_name'] 38 | else: 39 | self.iso_name = t[0] # type: ignore 40 | self.name = t[1] # type: ignore 41 | -------------------------------------------------------------------------------- /data/icons/symbolic/user-trash-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /data/icons/symbolic/network-transmit-receive-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /data/icons/symbolic/bell-outline-none-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/symbolic/update-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/ui/widgets/theme_switcher.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | using Gtk 4.0; 6 | 7 | template $ThemeSwitcher : Box { 8 | styles [ 9 | "themeswitcher", 10 | ] 11 | 12 | hexpand: true; 13 | 14 | Box box { 15 | hexpand: true; 16 | orientation: horizontal; 17 | spacing: 12; 18 | 19 | CheckButton system { 20 | styles [ 21 | "theme-selector", 22 | "system", 23 | ] 24 | 25 | visible: bind template.show-system; 26 | hexpand: true; 27 | halign: center; 28 | focus-on-click: false; 29 | tooltip-text: _("Follow system style"); 30 | notify::active => $_on_color_scheme_changed(); 31 | } 32 | 33 | CheckButton light { 34 | styles [ 35 | "theme-selector", 36 | "light", 37 | ] 38 | 39 | hexpand: true; 40 | halign: center; 41 | group: system; 42 | focus-on-click: false; 43 | tooltip-text: _("Light style"); 44 | notify::active => $_on_color_scheme_changed(); 45 | } 46 | 47 | CheckButton dark { 48 | styles [ 49 | "theme-selector", 50 | "dark", 51 | ] 52 | 53 | hexpand: true; 54 | halign: center; 55 | group: system; 56 | focus-on-click: false; 57 | tooltip-text: _("Dark style"); 58 | notify::active => $_on_color_scheme_changed(); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/ui/dialogs/message_dialogs.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2022 - 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | using Gtk 4.0; 6 | using Adw 1; 7 | 8 | Adw.AlertDialog _clear_cache_dialog { 9 | heading: C_("message dialog heading", "Clear Cached Data?"); 10 | body: C_("message dialog body", "This operation will clear all stored cache data. It might get downloaded again next time you add content from TMDB."); 11 | 12 | responses[ 13 | cache_cancel: C_("message dialog action", "Cancel"), 14 | cache_clear: C_("message dialog action", "Clear") destructive 15 | ] 16 | } 17 | 18 | Adw.AlertDialog _clear_data_dialog { 19 | heading: C_("message dialog heading", "Clear Stored Data?"); 20 | body: C_("message dialog body", "This operation will permanentlly delete the selected data, loosing all your changes."); 21 | extra-child: Adw.PreferencesGroup { 22 | Adw.ActionRow _movies_row { 23 | [prefix] 24 | CheckButton _movies_checkbtn {} 25 | 26 | title: _("Movies"); 27 | activatable-widget: _movies_checkbtn; 28 | } 29 | 30 | Adw.ActionRow _series_row { 31 | [prefix] 32 | CheckButton _series_checkbtn {} 33 | 34 | title: _("TV Series"); 35 | activatable-widget: _series_checkbtn; 36 | } 37 | }; 38 | 39 | responses[ 40 | data_cancel: C_("message dialog action", "Cancel"), 41 | data_clear: C_("message dialog action", "Clear") destructive 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /src/ticketbooth.in: -------------------------------------------------------------------------------- 1 | #!@python@ 2 | 3 | # ticketbooth.in 4 | # 5 | # Copyright 2023 Ale 6 | # 7 | # This program is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program. If not, see . 19 | # 20 | # SPDX-License-Identifier: GPL-3.0-or-later 21 | 22 | import os 23 | import sys 24 | import signal 25 | import locale 26 | import gettext 27 | 28 | version = '@version@' 29 | pkgdatadir = '@pkgdatadir@' 30 | localedir = '@localedir@' 31 | debug = '@debug@' 32 | 33 | sys.path.insert(1, pkgdatadir) 34 | signal.signal(signal.SIGINT, signal.SIG_DFL) 35 | locale.bindtextdomain('ticketbooth', localedir) 36 | locale.textdomain('ticketbooth') 37 | gettext.install('ticketbooth', localedir) 38 | 39 | if __name__ == '__main__': 40 | import gi 41 | 42 | from gi.repository import Gio 43 | resource = Gio.Resource.load(os.path.join(pkgdatadir, 'ticketbooth.gresource')) 44 | resource._register() 45 | 46 | from src import main 47 | sys.exit(main.main()) 48 | -------------------------------------------------------------------------------- /src/ui/gtk/help-overlay.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | using Gtk 4.0; 6 | 7 | ShortcutsWindow help_overlay { 8 | modal: true; 9 | 10 | ShortcutsSection { 11 | section-name: "shortcuts"; 12 | max-height: 10; 13 | 14 | ShortcutsGroup { 15 | title: C_("shortcut window", "General"); 16 | 17 | ShortcutsShortcut { 18 | title: C_("shortcut window", "Show Shortcuts"); 19 | action-name: "win.show-help-overlay"; 20 | } 21 | 22 | ShortcutsShortcut { 23 | title: C_("shortcut window", "Show Preferences"); 24 | action-name: "app.preferences"; 25 | } 26 | 27 | ShortcutsShortcut { 28 | title: C_("shortcut window", "Search your Watchlist"); 29 | accelerator: "f"; 30 | } 31 | 32 | ShortcutsShortcut { 33 | title: C_("shortcut window", "Quit"); 34 | action-name: "app.quit"; 35 | } 36 | } 37 | 38 | ShortcutsGroup { 39 | title: C_("shortcut window", "Library management"); 40 | 41 | ShortcutsShortcut { 42 | title: C_("shortcut window", "Add title from TMDB"); 43 | accelerator: "n"; 44 | } 45 | 46 | ShortcutsShortcut { 47 | title: C_("shortcut window", "Add title manually"); 48 | accelerator: "n"; 49 | } 50 | ShortcutsShortcut { 51 | title: C_("shortcut window", "Refresh library"); 52 | accelerator: "r"; 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/ui/widgets/image_selector.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2022 - 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | using Gtk 4.0; 6 | using Adw 1; 7 | 8 | template $ImageSelector: Adw.Bin { 9 | halign: center; 10 | 11 | map => $_on_map(); 12 | 13 | Overlay { 14 | height-request: 250; 15 | valign: center; 16 | halign: center; 17 | 18 | [overlay] 19 | Adw.Spinner _spinner { 20 | visible: false; 21 | } 22 | 23 | Overlay { 24 | halign: center; 25 | valign: center; 26 | 27 | [overlay] 28 | Button _edit_btn { 29 | icon-name: "document-edit"; 30 | tooltip-text: _("Edit poster"); 31 | halign: end; 32 | valign: end; 33 | margin-bottom: 6; 34 | margin-end: 6; 35 | 36 | styles ["circular", "osd"] 37 | clicked => $_on_edit_btn_clicked(); 38 | } 39 | 40 | [overlay] 41 | Revealer _delete_revealer { 42 | reveal-child: false; 43 | transition-type: crossfade; 44 | margin-end: 40; 45 | 46 | Button _delete_btn { 47 | icon-name: "user-trash-symbolic"; 48 | tooltip-text: _("Delete poster"); 49 | halign: end; 50 | valign: end; 51 | margin-bottom: 6; 52 | margin-end: 6; 53 | 54 | styles ["circular", "osd"] 55 | clicked => $_on_delete_btn_clicked(); 56 | } 57 | } 58 | 59 | Picture _poster_picture { 60 | content-fit: bind template.content-fit; 61 | 62 | styles ["poster"] 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/shared.py.in: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from gi.repository import Gdk, Gio, GLib 6 | from pathlib import Path 7 | import logging 8 | from .logging.session_file_handler import SessionFileHandler 9 | import faulthandler 10 | import os 11 | 12 | APP_ID = '@app_id@' 13 | VERSION = '@version@' 14 | PREFIX = '@prefix@' 15 | APP_NAME = '@app_name@' 16 | 17 | schema = Gio.Settings.new(APP_ID) 18 | 19 | data_dir = Path(GLib.get_user_data_dir()) 20 | cache_dir = Path(GLib.get_user_cache_dir()) 21 | 22 | poster_dir = data_dir / 'poster' 23 | background_dir = data_dir / 'background' 24 | series_dir = data_dir / 'series' 25 | 26 | db = data_dir / 'data.db' 27 | 28 | if not os.path.exists(data_dir/'logs'): 29 | os.makedirs(data_dir/'logs') 30 | faulthandler.enable(file=open(data_dir/'logs'/'crash.log',"w")) 31 | 32 | if '@debug@' == 'True': 33 | DEBUG = True 34 | logging.basicConfig(level=logging.DEBUG, 35 | format='%(asctime)s - %(levelname)s - %(message)s', 36 | filename=data_dir / 'logs' / 'ticketbooth.log') 37 | else: 38 | DEBUG = False 39 | logging.basicConfig(level=logging.INFO, 40 | format='%(asctime)s - %(levelname)s - %(message)s', 41 | filename=data_dir / 'logs' / 'ticketbooth.log') 42 | 43 | handler = SessionFileHandler(filename=data_dir / 'logs' / 'ticketbooth.log') 44 | handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')) 45 | logging.getLogger().addHandler(handler) 46 | 47 | log_files = None 48 | -------------------------------------------------------------------------------- /po/POTFILES: -------------------------------------------------------------------------------- 1 | data/me.iepure.Ticketbooth.desktop.in 2 | data/me.iepure.Ticketbooth.gschema.xml.in 3 | data/me.iepure.Ticketbooth.metainfo.xml.in 4 | 5 | src/background_queue.py 6 | src/main.py 7 | src/preferences.py 8 | src/window.py 9 | 10 | src/dialogs/add_manual_dialog.py 11 | src/dialogs/add_tmdb_dialog.py 12 | src/dialogs/edit_season_dialog.py 13 | 14 | src/pages/details_page.py 15 | src/pages/edit_episode_page.py 16 | 17 | src/ui/about_dialog.blp 18 | src/ui/dialogs/add_manual.blp 19 | src/ui/dialogs/add_tmdb.blp 20 | src/ui/dialogs/edit_season.blp 21 | src/ui/dialogs/message_dialogs.blp 22 | src/ui/gtk/help-overlay.blp 23 | src/ui/pages/details_page.blp 24 | src/ui/pages/edit_episode_page.blp 25 | src/ui/preferences.blp 26 | src/ui/views/content_view.blp 27 | src/ui/views/db_update_view.blp 28 | src/ui/views/first_run_view.blp 29 | src/ui/views/main_view.blp 30 | src/ui/widgets/background_activity_row.blp 31 | src/ui/widgets/background_indicator.blp 32 | src/ui/widgets/episode_row.blp 33 | src/ui/widgets/image_selector.blp 34 | src/ui/widgets/poster_button.blp 35 | src/ui/widgets/search_result_row.blp 36 | src/ui/widgets/season_expander.blp 37 | src/ui/widgets/theme_switcher.blp 38 | src/ui/window.blp 39 | 40 | src/views/content_view.py 41 | src/views/db_update_view.py 42 | src/views/first_run_view.py 43 | src/views/main_view.py 44 | 45 | src/widgets/background_activity_row.py 46 | src/widgets/background_indicator.py 47 | src/widgets/episode_row.py 48 | src/widgets/image_selector.py 49 | src/widgets/poster_button.py 50 | src/widgets/search_result_row.py 51 | src/widgets/season_expander.py 52 | src/widgets/theme_switcher.py 53 | 54 | -------------------------------------------------------------------------------- /src/ui/views/first_run_view.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | using Gtk 4.0; 6 | using Adw 1; 7 | 8 | template $FirstRunView: Adw.Bin { 9 | 10 | map => $_on_map(); 11 | 12 | Box { 13 | orientation: vertical; 14 | vexpand: true; 15 | 16 | Adw.HeaderBar { 17 | styles ["flat"] 18 | title-widget: Label {}; 19 | } 20 | 21 | Box { 22 | orientation: vertical; 23 | spacing: 12; 24 | valign: center; 25 | vexpand: true; 26 | 27 | Adw.Spinner { 28 | height-request: 64; 29 | } 30 | 31 | Box { 32 | orientation: vertical; 33 | margin-start: 12; 34 | margin-end: 12; 35 | 36 | Label _heading_lbl { 37 | label: "Getting things ready…"; 38 | wrap: true; 39 | halign: center; 40 | justify: center; 41 | styles ["title-1"] 42 | } 43 | 44 | Label _status_lbl { 45 | label: "Please wait, this might take a while."; 46 | halign: center; 47 | justify: center; 48 | wrap: true; 49 | } 50 | } 51 | 52 | Button _offline_btn { 53 | visible: false; 54 | halign: center; 55 | label: _("Use Offline Mode"); 56 | clicked => $_on_offline_btn_clicked(); 57 | styles ["suggested-action", "pill"] 58 | } 59 | 60 | CheckButton _retry_check_btn { 61 | label: _("Try again on next run"); 62 | visible: false; 63 | active: true; 64 | halign: center; 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /me.iepure.Ticketbooth.Devel.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "me.iepure.Ticketbooth.Devel", 3 | "runtime": "org.gnome.Platform", 4 | "runtime-version": "49", 5 | "sdk": "org.gnome.Sdk", 6 | "command": "ticketbooth", 7 | "separate-locales": false, 8 | "finish-args": [ 9 | "--share=network", 10 | "--share=ipc", 11 | "--socket=fallback-x11", 12 | "--device=dri", 13 | "--socket=wayland", 14 | "--talk-name=org.gtk.vfs.*", 15 | "--filesystem=xdg-run/gvfsd" 16 | ], 17 | "cleanup": [ 18 | "/include", 19 | "/lib/pkgconfig", 20 | "/man", 21 | "/share/doc", 22 | "/share/gtk-doc", 23 | "/share/man", 24 | "/share/pkgconfig", 25 | "*.la", 26 | "*.a" 27 | ], 28 | "modules": [ 29 | "pypi-dependencies.json", 30 | { 31 | "name": "blueprint-compiler", 32 | "buildsystem": "meson", 33 | "cleanup": [ 34 | "*" 35 | ], 36 | "sources": [ 37 | { 38 | "type": "git", 39 | "url": "https://gitlab.gnome.org/jwestman/blueprint-compiler", 40 | "tag": "v0.16.0" 41 | } 42 | ] 43 | }, 44 | { 45 | "name": "ticketbooth", 46 | "buildsystem": "meson", 47 | "config-opts": [ 48 | "-Dprerelease=true" 49 | ], 50 | "post-install": [ 51 | "mv /app/bin/ticketbooth /app/bin/ticketbooth-bin", 52 | "install -Dm755 /app/ticketbooth/ticketbooth-run-script /app/bin/ticketbooth" 53 | ], 54 | "sources": [ 55 | { 56 | "type": "git", 57 | "url": "https://github.com/aleiepure/ticketbooth", 58 | "branch": "main" 59 | } 60 | ] 61 | } 62 | ] 63 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Ticket Booth is a Linux app built with Python using the GTK4 toolkit and libadwaita. The only officially supported packaging format is Flatpak. 4 | 5 | ## Code 6 | 7 | When contributing to the source code, use the same style and conventions present in the files already in the repo. \ 8 | All contributions are and will always be welcome.\ 9 | If you have an idea, feel free to create an issue, and let's discuss it. You can also fork the repo, make your changes, and submit a pull request to change the code yourself. 10 | 11 | ## Translations 12 | 13 | This project is translated via [Weblate](https://hosted.weblate.org/engage/ticket-booth/) (preferred). Alternatively, you can translate manually by doing the following: 14 | 15 | 1. Clone the repository. 16 | 2. If it isn't already there, add your language to `/po/LINGUAS`. 17 | 3. Create a new translation from the `/po/ticketbooth.pot` file with a translation editor such as Poedit. 18 | 4. Save the file as `[YOUR LANGUAGE CODE].po` to `/po/`. 19 | 5. Create a pull request with your translations. 20 | 21 | ## Building 22 | 23 | ### Gnome Builder 24 | 25 | The quickest and easiest way 26 | 27 | 1. Install GNOME Builder. 28 | 2. Click "Clone Repository" with https://github.com/aleiepure/ticketbooth.git as the URL. 29 | 3. Click on the build button (hammer) at the top. 30 | 31 | ### Flatpak builder and other IDEs 32 | 33 | ```shell 34 | git clone https://github.com/aleiepure/ticketbooth 35 | flatpak-builder --repo=/path/to/repo/dir --force-clean --user /path/to/build/dir me.iepure.Ticketbooth.Devel.json 36 | flatpak remote-add --user ticketbooth ticketbooth --no-gpg-verify 37 | flatpak install --user ticketbooth me.iepure.Ticketbooth.Devel 38 | ``` 39 | 40 | Then run with 41 | 42 | ```shell 43 | flatpak run --user me.iepure.Ticketbooth.Devel 44 | ``` 45 | -------------------------------------------------------------------------------- /src/ui/widgets/background_indicator.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | using Gtk 4.0; 6 | using Gio 2.0; 7 | using Adw 1; 8 | 9 | template $BackgroundIndicator: Adw.Bin { 10 | MenuButton _btn { 11 | styles ["flat"] 12 | 13 | tooltip-text: _("Background Activities"); 14 | 15 | child: Overlay { 16 | [overlay] 17 | Adw.Spinner _spinner { 18 | visible: false; 19 | } 20 | 21 | Image _image { 22 | icon-name: 'check-plain'; 23 | } 24 | }; 25 | 26 | popover: Popover { 27 | styles ["menu"] 28 | width-request: 300; 29 | 30 | child: Adw.ViewStack _stack { 31 | Adw.ViewStackPage { 32 | name: 'empty'; 33 | child: Adw.StatusPage { 34 | title: _("No Background Activities"); 35 | icon-name: 'check-plain'; 36 | }; 37 | } 38 | 39 | Adw.ViewStackPage { 40 | name: 'filled'; 41 | child: ScrolledWindow { 42 | vexpand: true; 43 | 44 | ListView _list_view { 45 | orientation: vertical; 46 | model: NoSelection { 47 | model: Gio.ListStore _model {}; 48 | }; 49 | factory: BuilderListItemFactory { 50 | template ListItem { 51 | child: $BackgroundActivityRow { 52 | title: bind template.item as < $BackgroundActivity > .title; 53 | activity-type: bind template.item as < $BackgroundActivity > .activity-type; 54 | completed: bind template.item as < $BackgroundActivity > .completed; 55 | }; 56 | } 57 | }; 58 | } 59 | }; 60 | } 61 | }; 62 | }; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/models/search_result_model.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import re 6 | 7 | from gi.repository import GObject 8 | 9 | 10 | class SearchResultModel(GObject.GObject): 11 | """ 12 | This class represents the object returned from the TMDB search endpoint. 13 | 14 | Properties: 15 | title (str): content's title 16 | year (str): content's release year 17 | description (str): content's description 18 | poster_path (str): API endpoint for the content poster 19 | tmdb_id (int): content's unique id in the API 20 | media_type (str): content's media type 21 | 22 | Methods: 23 | None 24 | 25 | Signals: 26 | None 27 | """ 28 | 29 | __gtype_name__ = 'SearchResultModel' 30 | 31 | title = GObject.Property(type=str, default='') 32 | year = GObject.Property(type=str, default='') 33 | description = GObject.Property(type=str, default='') 34 | poster_path = GObject.Property(type=str, default='') 35 | tmdb_id = GObject.Property(type=int, default=0) 36 | media_type = GObject.Property(type=str, default='') 37 | 38 | def __init__(self, d=None): 39 | super().__init__() 40 | 41 | if d is not None: 42 | self.tmdb_id = d['id'] 43 | self.poster_path = d['poster_path'] 44 | self.description = re.sub(r'\s{2}', ' ', d['overview']) 45 | 46 | if d['media_type'] == 'movie': 47 | self.media_type = d['media_type'] 48 | self.title = d['title'] 49 | self.year = d['release_date'][0:4] 50 | elif d['media_type'] == 'tv': 51 | self.media_type = d['media_type'] 52 | self.title = d['name'] 53 | self.year = d['first_air_date'][0:4] 54 | -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | desktop_file = i18n.merge_file( 6 | input: configure_file( 7 | input: 'me.iepure.Ticketbooth.desktop.in', 8 | output: app_id + '.desktop.in', 9 | configuration: conf 10 | ), 11 | output: app_id + '.desktop', 12 | type: 'desktop', 13 | po_dir: '../po', 14 | install: true, 15 | install_dir: join_paths(get_option('datadir'), 'applications') 16 | ) 17 | 18 | desktop_utils = find_program('desktop-file-validate', required: false) 19 | if desktop_utils.found() 20 | test('Validate desktop file', desktop_utils, args: [desktop_file]) 21 | endif 22 | 23 | appstream_file = i18n.merge_file( 24 | input: configure_file( 25 | input: 'me.iepure.Ticketbooth.metainfo.xml.in', 26 | output: app_id + '.metainfo.xml.in', 27 | configuration: conf 28 | ), 29 | output: app_id + '.metainfo.xml', 30 | po_dir: '../po', 31 | install: true, 32 | install_dir: join_paths(get_option('datadir'), 'metainfo') 33 | ) 34 | 35 | appstreamcli = find_program('appstreamcli', required: false) 36 | if appstreamcli.found() 37 | test('Validate appstream file', appstreamcli, args: ['validate', appstream_file]) 38 | endif 39 | 40 | install_data( 41 | configure_file( 42 | input: 'me.iepure.Ticketbooth.gschema.xml.in', 43 | output: app_id + '.gschema.xml', 44 | configuration: conf 45 | ), 46 | install_dir: join_paths(get_option('datadir'), 'glib-2.0/schemas') 47 | ) 48 | 49 | 50 | compile_schemas = find_program('glib-compile-schemas', required: false) 51 | if compile_schemas.found() 52 | test('Validate schema file', 53 | compile_schemas, 54 | args: ['--strict', '--dry-run', meson.current_source_dir()]) 55 | endif 56 | 57 | subdir('icons') 58 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | project('ticketbooth', 6 | version: '1.2.0', 7 | meson_version: '>= 0.62.0', 8 | default_options: [ 'warning_level=2', 'werror=false', ], 9 | ) 10 | 11 | i18n = import('i18n') 12 | gnome = import('gnome') 13 | 14 | # Debug info 15 | if get_option('prerelease') 16 | name_suffix = ' (Development snapshot)' 17 | app_id = 'me.iepure.Ticketbooth.Devel' 18 | prefix = '/me/iepure/Ticketbooth/Devel' 19 | 20 | git = find_program('git', required : false) 21 | if git.found() 22 | git_commit = run_command('git', 'rev-parse', '--short', 'HEAD', check:false).stdout().strip() 23 | endif 24 | 25 | if git_commit == '' 26 | version_number = '-Devel' 27 | else 28 | version_number = 'Git-@0@'.format(git_commit) 29 | endif 30 | 31 | else 32 | name_suffix = '' 33 | app_id = 'me.iepure.Ticketbooth' 34 | prefix = '/me/iepure/Ticketbooth' 35 | version_number = meson.project_version() 36 | endif 37 | 38 | 39 | # Python setup 40 | python = import('python') 41 | 42 | # Config data 43 | conf = configuration_data() 44 | conf.set('python', python.find_installation('python3').full_path()) 45 | conf.set('localedir', join_paths(get_option('prefix'), get_option('localedir'))) 46 | conf.set('pkgdatadir', join_paths(get_option('prefix'), get_option('datadir'), meson.project_name())) 47 | conf.set('debug', get_option('prerelease')) 48 | conf.set('app_id', app_id) 49 | conf.set('version', version_number) 50 | conf.set('app_name', 'Ticket Booth@0@'.format(name_suffix)) 51 | conf.set('prefix', prefix) 52 | 53 | subdir('data') 54 | subdir('src') 55 | subdir('po') 56 | 57 | install_data( 58 | configure_file( 59 | input: 'install/ticketbooth-run-script.in', 60 | output: 'ticketbooth-run-script', 61 | configuration: conf 62 | ), 63 | install_dir: join_paths(get_option('prefix'), meson.project_name()) 64 | ) 65 | 66 | gnome.post_install( 67 | glib_compile_schemas: true, 68 | gtk_update_icon_cache: true, 69 | update_desktop_database: true, 70 | ) 71 | -------------------------------------------------------------------------------- /data/icons/symbolic/watchlist-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 39 | -------------------------------------------------------------------------------- /src/ui/widgets/episode_row.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | using Gtk 4.0; 6 | using Adw 1; 7 | 8 | template $EpisodeRow: Adw.PreferencesRow { 9 | 10 | map => $_on_map(); 11 | 12 | Box { 13 | margin-bottom: 12; 14 | margin-top: 12; 15 | margin-start: 12; 16 | margin-end: 12; 17 | orientation: horizontal; 18 | hexpand: true; 19 | spacing: 24; 20 | 21 | Picture _still_picture { 22 | height-request: 100; 23 | width-request: 56; 24 | content-fit: fill; 25 | 26 | styles ["still"] 27 | } 28 | 29 | Box { 30 | orientation: vertical; 31 | hexpand: true; 32 | 33 | Label _title_lbl { 34 | halign: start; 35 | lines: 2; 36 | wrap: true; 37 | 38 | styles ["heading"] 39 | } 40 | 41 | Label _runtime_lbl { 42 | halign: start; 43 | 44 | styles ["dimmed"] 45 | } 46 | 47 | Label _overview { 48 | margin-top: 3; 49 | halign: start; 50 | label: bind template.overview; 51 | wrap: true; 52 | lines: 3; 53 | wrap-mode: word; 54 | ellipsize: end; 55 | } 56 | } 57 | 58 | Box { 59 | visible: bind template.show-controls; 60 | 61 | Button _watched_btn { 62 | visible: bind template.editable inverted; 63 | valign: center; 64 | 65 | clicked => $_on_watched_btn_clicked(); 66 | } 67 | 68 | Box { 69 | styles ["linked"] 70 | 71 | orientation: horizontal; 72 | visible: bind template.editable; 73 | halign: end; 74 | hexpand: true; 75 | 76 | Button _delete_btn { 77 | valign: center; 78 | icon-name: "user-trash-symbolic"; 79 | 80 | clicked => $_on_delete_btn_clicked(); 81 | } 82 | 83 | Button _edit_btn { 84 | valign: center; 85 | child: Adw.ButtonContent { 86 | icon-name: "document-edit"; 87 | label: _("Edit"); 88 | }; 89 | 90 | clicked => $_on_edit_btn_clicked(); 91 | } 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /ticketbooth.doap: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 14 | 15 | Ticket Booth 16 | Keep track of your favorite shows 17 | 18 | Ticket Booth allows you to build your watchlist of movies and TV Shows, keep track of 19 | watched titles, and find information about the latest releases. Ticket Booth does not allow 20 | you to watch or download content. This app uses the TMDB API but is not endorsed or 21 | certified by TMDB. 22 | 23 | 24 | 25 | 26 | 27 | 28 | Python 29 | GTK 4 30 | Libadwaita 31 | 32 | 33 | 34 | Alessandro Iepure 35 | 36 | 37 | 38 | 39 | aleiepure 40 | 41 | 42 | 43 | 44 | 45 | aleiepure 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/ui/dialogs/edit_season.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2022 - 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | using Gtk 4.0; 5 | using Adw 1; 6 | 7 | template $EditSeasonDialog: Adw.Dialog { 8 | content-width: 600; 9 | content-height: 640; 10 | map => $_on_map(); 11 | 12 | Adw.NavigationView _navigation_view { 13 | Adw.NavigationPage { 14 | title: _("Edit Season"); 15 | 16 | child: Adw.ToolbarView { 17 | [top] 18 | Adw.HeaderBar { 19 | show-end-title-buttons: false; 20 | show-start-title-buttons: false; 21 | 22 | [start] 23 | Button _cancel_btn { 24 | label: _("Cancel"); 25 | action-name: 'window.close'; 26 | } 27 | 28 | [end] 29 | Button _save_btn { 30 | label: _("Save"); 31 | sensitive: false; 32 | clicked => $_on_save_btn_clicked(); 33 | 34 | styles [ 35 | "suggested-action" 36 | ] 37 | } 38 | } 39 | 40 | content: ScrolledWindow { 41 | 42 | Box { 43 | orientation: vertical; 44 | margin-start: 20; 45 | margin-end: 20; 46 | margin-bottom: 20; 47 | 48 | $ImageSelector _poster {} 49 | 50 | Adw.PreferencesGroup { 51 | margin-bottom: 20; 52 | title: _("General"); 53 | 54 | Adw.EntryRow _title_entry { 55 | title: _("Title (required)"); 56 | changed => $_on_title_entry_changed(); 57 | } 58 | } 59 | 60 | Adw.PreferencesGroup _episodes_group { 61 | title: _("Episodes (required)"); 62 | description: _("Use the + button to add episodes"); 63 | 64 | [header-suffix] 65 | Button { 66 | Adw.ButtonContent { 67 | label: _("Add"); 68 | icon-name: "plus"; 69 | } 70 | 71 | clicked => $_on_add_btn_clicked(); 72 | styles ["accent"] 73 | } 74 | } 75 | } 76 | }; 77 | }; 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /data/icons/symbolic/star-large-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/ui/pages/edit_episode_page.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2022 - 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | using Gtk 4.0; 5 | using Adw 1; 6 | 7 | template $EditEpisodeNavigationPage: Adw.NavigationPage { 8 | map => $_on_map(); 9 | title: _("Edit Episode"); 10 | 11 | child: Adw.ToolbarView { 12 | [top] 13 | Adw.HeaderBar { 14 | show-end-title-buttons: false; 15 | show-start-title-buttons: false; 16 | 17 | [end] 18 | Button _save_btn { 19 | label: _("Save"); 20 | sensitive: bind $_enable_save(_title_entry.text,(_episode_spin_row.value) as ) as ; 21 | clicked => $_on_save_btn_clicked(); 22 | 23 | styles [ 24 | "suggested-action" 25 | ] 26 | } 27 | } 28 | 29 | content: ScrolledWindow { 30 | Box { 31 | orientation: vertical; 32 | margin-start: 20; 33 | margin-end: 20; 34 | margin-bottom: 20; 35 | 36 | $ImageSelector _still { 37 | content-fit: cover; 38 | } 39 | 40 | Adw.PreferencesGroup { 41 | title: _("General"); 42 | margin-bottom: 20; 43 | 44 | Adw.SpinRow _episode_spin_row { 45 | title: _("Episode Number (required)"); 46 | 47 | adjustment: Adjustment { 48 | lower: 0; 49 | upper: 900; 50 | step-increment: 1; 51 | }; 52 | } 53 | 54 | Adw.EntryRow _title_entry { 55 | title: _("Title (required)"); 56 | use-markup: true; 57 | } 58 | 59 | Adw.SpinRow _runtime_spin_row { 60 | title: _("Runtime (minutes)"); 61 | 62 | adjustment: Adjustment { 63 | lower: 0; 64 | upper: 900; 65 | step-increment: 1; 66 | }; 67 | } 68 | } 69 | 70 | Adw.PreferencesGroup { 71 | title: _("Overview"); 72 | 73 | Gtk.TextView _overview_text { 74 | height-request: 100; 75 | top-margin: 12; 76 | bottom-margin: 12; 77 | right-margin: 12; 78 | left-margin: 12; 79 | wrap-mode: word; 80 | 81 | styles [ 82 | "card" 83 | ] 84 | } 85 | } 86 | } 87 | }; 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /data/icons/symbolic/hourglass-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/hicolor/symbolic/apps/me.iepure.Ticketbooth-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /data/icons/hicolor/symbolic/apps/me.iepure.Ticketbooth.Devel-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/meson.build: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 6 | moduledir = join_paths(pkgdatadir, 'src') 7 | gnome = import('gnome') 8 | 9 | # Compile resources 10 | blueprints = custom_target('blueprints', 11 | input: files( 12 | 'ui/gtk/help-overlay.blp', 13 | 'ui/window.blp', 14 | 'ui/about_dialog.blp', 15 | 'ui/preferences.blp', 16 | 17 | 'ui/views/main_view.blp', 18 | 'ui/views/first_run_view.blp', 19 | 'ui/views/db_update_view.blp', 20 | 'ui/views/content_view.blp', 21 | 22 | 'ui/dialogs/add_manual.blp', 23 | 'ui/dialogs/add_tmdb.blp', 24 | 'ui/dialogs/edit_season.blp', 25 | 'ui/dialogs/message_dialogs.blp', 26 | 27 | 'ui/pages/edit_episode_page.blp', 28 | 'ui/pages/details_page.blp', 29 | 30 | 'ui/widgets/theme_switcher.blp', 31 | 'ui/widgets/poster_button.blp', 32 | 'ui/widgets/search_result_row.blp', 33 | 'ui/widgets/episode_row.blp', 34 | 'ui/widgets/image_selector.blp', 35 | 'ui/widgets/season_expander.blp', 36 | 'ui/widgets/background_indicator.blp', 37 | 'ui/widgets/background_activity_row.blp', 38 | ), 39 | output: '.', 40 | command: [find_program('blueprint-compiler'), 'batch-compile', '@OUTPUT@', '@CURRENT_SOURCE_DIR@', '@INPUT@'] 41 | ) 42 | 43 | gnome.compile_resources('ticketbooth', 44 | configure_file( 45 | input: 'ticketbooth.gresource.xml.in', 46 | output: 'ticketbooth.gresource.xml', 47 | configuration: conf 48 | ), 49 | gresource_bundle: true, 50 | install: true, 51 | install_dir: pkgdatadir, 52 | dependencies: blueprints, 53 | ) 54 | 55 | configure_file( 56 | input: 'ticketbooth.in', 57 | output: 'ticketbooth', 58 | configuration: conf, 59 | install: true, 60 | install_dir: get_option('bindir') 61 | ) 62 | 63 | subdir('widgets') 64 | subdir('dialogs') 65 | subdir('providers') 66 | subdir('models') 67 | subdir('views') 68 | subdir('pages') 69 | subdir('logging') 70 | 71 | # Install sources 72 | ticketbooth_sources = [ 73 | '__init__.py', 74 | 'main.py', 75 | 'window.py', 76 | configure_file( 77 | input: 'shared.py.in', 78 | output: 'shared.py', 79 | configuration: conf 80 | ), 81 | 'preferences.py', 82 | 'background_queue.py', 83 | ] 84 | 85 | install_data(ticketbooth_sources, install_dir: moduledir) 86 | -------------------------------------------------------------------------------- /src/dialogs/add_tmdb_dialog.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import logging 6 | 7 | from gi.repository import Adw, Gio, GLib, GObject, Gtk 8 | 9 | from .. import shared # type: ignore 10 | from ..models.search_result_model import SearchResultModel 11 | from ..providers.tmdb_provider import TMDBProvider 12 | 13 | 14 | @Gtk.Template(resource_path=shared.PREFIX + '/ui/dialogs/add_tmdb.ui') 15 | class AddTMDBDialog(Adw.Dialog): 16 | """ 17 | This class represents the dialog used to search for movies and tv-series on TMDB. 18 | 19 | Properties: 20 | None 21 | 22 | Methods: 23 | None 24 | 25 | Signals: 26 | None 27 | """ 28 | 29 | __gtype_name__ = 'AddTMDBDialog' 30 | 31 | _search_entry = Gtk.Template.Child() 32 | _stack = Gtk.Template.Child() 33 | _model = Gtk.Template.Child() 34 | 35 | def __init__(self): 36 | super().__init__() 37 | 38 | @Gtk.Template.Callback('_on_searchentry_search_changed') 39 | def _on_searchentry_search_changed(self, user_data: object | None) -> None: 40 | """ 41 | Callback for the "seach-changed" signal. 42 | Updates the GtkListModel used by the factory to populate the GtkListView. 43 | 44 | Args: 45 | user_data (object or None): user data passed to the callback. 46 | 47 | Returns: 48 | None 49 | """ 50 | 51 | logging.info(f'Search query: "{self._search_entry.get_text()}"') 52 | 53 | if self._model.get_property('n-items') > 0: 54 | self._model.remove_all() 55 | 56 | if not self._search_entry.get_text(): 57 | self._stack.set_visible_child_name('empty') 58 | return 59 | 60 | response = TMDBProvider().search(query=self._search_entry.get_text()) 61 | if not response['results']: 62 | self._stack.set_visible_child_name('no-results') 63 | logging.info('No results for query') 64 | return 65 | 66 | for result in response['results']: 67 | if result['media_type'] in ['movie', 'tv']: 68 | search_result = SearchResultModel(result) 69 | logging.info( 70 | f'Found [{"movie" if search_result.media_type == "movie" else "TV series"}] {search_result.title}, {search_result.year}') 71 | self._model.append(search_result) 72 | 73 | self._stack.set_visible_child_name('results') 74 | -------------------------------------------------------------------------------- /data/icons/symbolic/bell-outline-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/widgets/background_indicator.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from gi.repository import Adw, Gio, GObject, Gtk 6 | 7 | from .. import shared # type: ignore 8 | from ..background_queue import BackgroundQueue 9 | 10 | 11 | @Gtk.Template(resource_path=shared.PREFIX + '/ui/widgets/background_indicator.ui') 12 | class BackgroundIndicator(Adw.Bin): 13 | """ 14 | This class represents the indicator for background activities. 15 | 16 | Properties: 17 | queue (Gio.ListStore): the queue 18 | 19 | Methods: 20 | refresh(): updates the icon button 21 | 22 | Signals: 23 | None 24 | """ 25 | 26 | __gtype_name__ = 'BackgroundIndicator' 27 | 28 | queue = GObject.Property(type=Gio.ListStore) 29 | 30 | _stack = Gtk.Template.Child() 31 | _model = Gtk.Template.Child() 32 | _image = Gtk.Template.Child() 33 | _spinner = Gtk.Template.Child() 34 | _list_view = Gtk.Template.Child() 35 | 36 | def __init__(self): 37 | super().__init__() 38 | self.queue = BackgroundQueue.get_queue() 39 | 40 | self._spinner.bind_property('visible', self._image, 'visible', GObject.BindingFlags.INVERT_BOOLEAN) 41 | self.queue.connect('notify::n-items', self._on_queue_change) 42 | 43 | def _on_queue_change(self, pspec: GObject.ParamSpec, user_data: object | None) -> None: 44 | """ 45 | Callback for "notify::n-items" signal. 46 | Updates the model and refreshes the indicator. 47 | 48 | Args: 49 | pspec (GObject.ParamSpec): The GParamSpec of the property which changed 50 | user_data (object or None): additional data passed to the callback 51 | 52 | Returns: 53 | None 54 | """ 55 | 56 | if self.queue.get_property('n-items') > 0: 57 | 58 | self._model.remove_all() 59 | for activity in self.queue: 60 | self._model.append(activity) 61 | 62 | self._stack.set_visible_child_name('filled') 63 | self.refresh() 64 | 65 | def refresh(self) -> None: 66 | """ 67 | Checks the activities and shows a spinner as the button icon if at least one activity is running. If all activities are completed, the icon is set to a check mark. 68 | 69 | Args: 70 | None 71 | 72 | Returns: 73 | None 74 | """ 75 | 76 | self._spinner.set_visible(not all(activity.completed for activity in self.queue)) 77 | -------------------------------------------------------------------------------- /src/ui/dialogs/add_tmdb.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | using Gtk 4.0; 6 | using Gio 2.0; 7 | using Adw 1; 8 | 9 | template $AddTMDBDialog: Adw.Dialog { 10 | focus-widget: _search_entry; 11 | content-width: 700; 12 | content-height: 550; 13 | 14 | ShortcutController { 15 | Shortcut { 16 | trigger: "Escape"; 17 | action: "action(window.close)"; 18 | } 19 | } 20 | 21 | Adw.ToolbarView { 22 | 23 | [top] 24 | Adw.HeaderBar { 25 | title-widget: SearchEntry _search_entry { 26 | activates-default: true; 27 | placeholder-text: _("Search The Movie Database…"); 28 | search-delay: 500; 29 | search-changed => $_on_searchentry_search_changed(); 30 | }; 31 | } 32 | 33 | content: Adw.ViewStack _stack { 34 | Adw.ViewStackPage { 35 | name: "empty"; 36 | child: Adw.StatusPage { 37 | title: _("Search For a Title"); 38 | icon-name: "loupe"; 39 | description: _("Start typing in the search bar to see a list of matching movies and TV series"); 40 | }; 41 | } 42 | 43 | Adw.ViewStackPage { 44 | name: "no-results"; 45 | child: Adw.StatusPage { 46 | title: _("No Results Found"); 47 | icon-name: "loupe"; 48 | description: _("Try a different search"); 49 | }; 50 | } 51 | 52 | Adw.ViewStackPage { 53 | name: "results"; 54 | child: ScrolledWindow { 55 | vexpand: true; 56 | 57 | ListView _list_view { 58 | styles ["navigation-sidebar"] 59 | 60 | orientation: vertical; 61 | model: NoSelection { 62 | model: Gio.ListStore _model {}; 63 | }; 64 | factory: BuilderListItemFactory { 65 | template ListItem { 66 | child: $SearchResultRow { 67 | tmdb-id: bind template.item as < $SearchResultModel > .tmdb-id; 68 | title: bind template.item as < $SearchResultModel > .title; 69 | year: bind template.item as < $SearchResultModel > .year; 70 | media-type: bind template.item as < $SearchResultModel > .media-type; 71 | description: bind template.item as < $SearchResultModel > .description; 72 | poster-path: bind template.item as < $SearchResultModel > .poster-path; 73 | }; 74 | } 75 | }; 76 | } 77 | }; 78 | } 79 | }; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/ui/widgets/search_result_row.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2022 - 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | using Gtk 4.0; 6 | using Adw 1; 7 | 8 | template $SearchResultRow: ListBoxRow { 9 | 10 | map => $_on_map(); 11 | css-name: "search_result_row"; 12 | 13 | hexpand: true; 14 | vexpand: true; 15 | activatable: false; 16 | child: Box { 17 | valign: start; 18 | vexpand: true; 19 | spacing: 12; 20 | orientation: horizontal; 21 | margin-top: 6; 22 | margin-bottom: 6; 23 | 24 | Overlay { 25 | 26 | [overlay] 27 | Adw.Spinner _poster_spinner { 28 | height-request: 32; 29 | width-request: 32; 30 | valign: center; 31 | } 32 | 33 | Picture _poster_picture { 34 | width-request: 100; 35 | height-request: 150; 36 | 37 | styles ["poster"] 38 | } 39 | } 40 | 41 | Box { 42 | orientation: vertical; 43 | 44 | Label _title_lbl { 45 | halign: start; 46 | ellipsize: end; 47 | hexpand: true; 48 | label: bind template.title; 49 | styles ["heading"] 50 | } 51 | 52 | Box _caption_box { 53 | orientation: horizontal; 54 | spacing: 12; 55 | halign: start; 56 | 57 | Label _year_lbl { 58 | hexpand: true; 59 | halign: start; 60 | label: bind template.year; 61 | styles ["caption", "chip"] 62 | visible: bind template.year-visible; 63 | } 64 | 65 | Label _media_type_lbl { 66 | label: bind template.media_type; 67 | styles ["caption", "chip"] 68 | } 69 | } 70 | 71 | Label _description { 72 | hexpand: true; 73 | margin-top: 6; 74 | halign: start; 75 | wrap: true; 76 | wrap-mode: word; 77 | label: bind template.description; 78 | lines: 4; 79 | ellipsize: end; 80 | } 81 | 82 | Box { 83 | orientation: horizontal; 84 | spacing: 12; 85 | valign: end; 86 | 87 | Button { 88 | child: Adw.ButtonContent _add_btn { 89 | label: _("Add to watchlist"); 90 | icon-name: "plus"; 91 | }; 92 | halign: start; 93 | vexpand: true; 94 | valign: end; 95 | 96 | clicked => $_on_add_btn_clicked(); 97 | 98 | styles ["suggested-action"] 99 | } 100 | 101 | Adw.Spinner _add_spinner { 102 | height-request: 16; 103 | visible: false; 104 | } 105 | } 106 | } 107 | }; 108 | } 109 | -------------------------------------------------------------------------------- /src/ui/views/db_update_view.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | using Gtk 4.0; 6 | using Adw 1; 7 | 8 | template $DbUpdateView: Adw.Bin { 9 | map => $_on_map(); 10 | 11 | 12 | Box { 13 | orientation: vertical; 14 | 15 | Adw.HeaderBar { 16 | styles ["flat"] 17 | title-widget: Label {}; 18 | } 19 | 20 | Adw.Carousel _carousel { 21 | vexpand: true; 22 | hexpand: true; 23 | allow-scroll-wheel: false; 24 | allow-mouse-drag: false; 25 | allow-long-swipes: false; 26 | 27 | Adw.StatusPage _update_progress_page { 28 | margin-top: 64; 29 | title: _("Updating your watchlist"); 30 | description: _("After updating Ticket Booth we need to update the local database entries"); 31 | icon-name: "network-transmit-receive"; 32 | vexpand: true; 33 | hexpand: true; 34 | Box { 35 | margin-top: 42; 36 | orientation: vertical; 37 | halign: center; 38 | valign: center; 39 | 40 | ProgressBar _progress_bar{ 41 | fraction: 0.0; 42 | show-text: true; 43 | margin-bottom: 24; 44 | } 45 | 46 | Button _offline_btn { 47 | visible: true; 48 | halign: center; 49 | label: _("Use Offline Mode"); 50 | clicked => $_on_offline_btn_clicked(); 51 | styles ["suggested-action", "pill"] 52 | } 53 | 54 | CheckButton _retry_check_btn { 55 | label: _("Try again on next run"); 56 | visible: true; 57 | active: true; 58 | halign: center; 59 | } 60 | } 61 | } 62 | 63 | Adw.StatusPage _notification_question { 64 | margin-top: 64; 65 | margin-end: 20; 66 | title: _("Activate Notifications?"); 67 | description: _("Would you like to activate notifications for all series that are still in production and for all movies that have not yet released?"); 68 | icon-name: "bell-outline-symbolic"; 69 | vexpand: true; 70 | hexpand: true; 71 | Box { 72 | orientation: horizontal; 73 | halign: center; 74 | Button _activate_btn { 75 | margin-end: 8; 76 | label: _("Activate"); 77 | use-underline: true; 78 | halign: center; 79 | clicked => $_on_activate_btn_clicked(); 80 | styles ["suggested-action", "pill"] 81 | } 82 | 83 | Button _deactivate_btn { 84 | margin-end: 8; 85 | label: _("Deactivate"); 86 | use-underline: true; 87 | halign: center; 88 | clicked => $_on_deactivate_btn_clicked(); 89 | styles ["destructive-action", "pill" ] 90 | } 91 | } 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |

Ticket Booth Icon
Ticket Booth

9 |

Keep track of your favorite shows

10 |
11 |
12 | 13 | CI workflow status 14 | 15 | 16 | GPL 3 License 17 | 18 | 19 | Translation status 20 | 21 | 22 | Please do not theme this app 23 | 24 |
25 | Features · 26 | Install · 27 | Contribute · 28 | License 29 |
30 | 31 | ## Features 32 | 33 | Ticket Booth allows you to build your watchlist of movies and TV Shows, keep track of watched titles, and find information about the latest releases. 34 | 35 | Ticket Booth does not allow you to watch or download content. 36 | This app uses the TMDB API but is not endorsed or certified by TMDB. 37 | 38 | ## Install 39 | 40 | 41 | Download on Flathub 42 |
43 | 44 | Builds from the main branch are available as artifacts on the [Actions page](https://github.com/aleiepure/ticketbooth/actions).\ 45 | To build from source see [Building](./CONTRIBUTING.md#building). 46 | 47 | ## Contribute 48 | 49 | This project is translated via [Weblate](https://hosted.weblate.org/engage/ticket-booth/). \ 50 | 51 | Translation status 52 | 53 | 54 | See [Contributing](./CONTRIBUTING.md) to learn more. 55 | 56 | ## License 57 | 58 | Copyright (C) 2023-2025 Alessandro Iepure\ 59 | This application comes with absolutely no warranty. See the GNU General Public 60 | License, version 3 or later for details. A [copy of the license](./LICENSES/GPL-3.0-or-later.txt) 61 | can be found in the [LICENSES/](./LICENSES/) folder. 62 | 63 | Most symbolic icons are copyright of the GNOME Project.\ 64 | The libraries used are the copyright of the respective copyright holders. 65 | -------------------------------------------------------------------------------- /data/icons/symbolic/movies-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 47 | -------------------------------------------------------------------------------- /src/widgets/theme_switcher.py: -------------------------------------------------------------------------------- 1 | # Code modified from https://gitlab.gnome.org/tijder/blueprintgtk 2 | # Original header below 3 | # --------------------- 4 | # 5 | # Copyright 2020 Manuel Genovés 6 | # Copyright 2022 Mufeed Ali 7 | # Copyright 2022 Rafael Mardojai CM 8 | # SPDX-License-Identifier: GPL-3.0-or-later 9 | # 10 | # Code modified from Apostrophe 11 | # https://github.com/dialect-app/dialect/blob/c0b7ca0580d4c7cfb32ff7ed0a3a08c06bbe40e0/dialect/theme_switcher.py 12 | 13 | from gi.repository import Adw, Gdk, Gio, GObject, Gtk 14 | 15 | from .. import shared # type: ignore 16 | 17 | 18 | @Gtk.Template(resource_path=shared.PREFIX + '/ui/widgets/theme_switcher.ui') 19 | class ThemeSwitcher(Gtk.Box): 20 | __gtype_name__ = 'ThemeSwitcher' 21 | 22 | __gsignals__ = { 23 | 'themer-clicked': (GObject.SIGNAL_RUN_FIRST, None, ()), 24 | } 25 | 26 | show_system = GObject.property(type=bool, default=True) 27 | color_scheme = 'light' 28 | 29 | 30 | system = Gtk.Template.Child() 31 | light = Gtk.Template.Child() 32 | dark = Gtk.Template.Child() 33 | 34 | @GObject.Property(type=str) 35 | def selected_color_scheme(self): # type: ignore 36 | """Read-write integer property.""" 37 | 38 | return self.color_scheme 39 | 40 | @selected_color_scheme.setter 41 | def set_selected_color_scheme(self, color_scheme): 42 | self.color_scheme = color_scheme 43 | 44 | if color_scheme == 'auto': 45 | self.system.set_active(True) 46 | self.style_manager.set_color_scheme(Adw.ColorScheme.PREFER_LIGHT) 47 | if color_scheme == 'light': 48 | self.light.set_active(True) 49 | self.style_manager.set_color_scheme(Adw.ColorScheme.FORCE_LIGHT) 50 | if color_scheme == 'dark': 51 | self.dark.set_active(True) 52 | self.style_manager.set_color_scheme(Adw.ColorScheme.FORCE_DARK) 53 | 54 | def __init__(self, **kwargs): 55 | super().__init__(**kwargs) 56 | 57 | self.style_manager = Adw.StyleManager.get_default() 58 | 59 | self.style_manager.bind_property( 60 | 'system-supports-color-schemes', 61 | self, 'show_system', 62 | GObject.BindingFlags.SYNC_CREATE 63 | ) 64 | 65 | self.selected_color_scheme = shared.schema.get_string('style-scheme') 66 | 67 | @Gtk.Template.Callback() 68 | def _on_color_scheme_changed(self, _widget, _paramspec): 69 | if self.system.get_active(): 70 | self.selected_color_scheme = 'auto' # type: ignore 71 | shared.schema.set_string('style-scheme', 'auto') 72 | if self.light.get_active(): 73 | self.selected_color_scheme = 'light' # type: ignore 74 | shared.schema.set_string('style-scheme', 'light') 75 | if self.dark.get_active(): 76 | self.selected_color_scheme = 'dark' # type: ignore 77 | shared.schema.set_string('style-scheme', 'dark') 78 | # emit signal so we can change the background of details page if needed 79 | self.emit('themer-clicked') 80 | -------------------------------------------------------------------------------- /data/icons/symbolic/check-round-outline-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/css/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Alessandro Iepure 3 | * 4 | * SPDX-License-Identifier: GPL-3.0-or-later 5 | */ 6 | 7 | /* Light mode CSS - Main style */ 8 | 9 | .notes { 10 | border-radius: 12px; 11 | background-color: alpha(#dddddd, 0.2); 12 | } 13 | 14 | .chip { 15 | padding: 0 7px; 16 | line-height: 20px; 17 | border-radius: 25px; 18 | background-color: @light_3; 19 | } 20 | 21 | .poster { 22 | border-radius: 12px; 23 | } 24 | 25 | .light { 26 | color: @light_2; 27 | } 28 | 29 | .dark { 30 | color: @dark_3; 31 | } 32 | 33 | .still { 34 | border-radius: 6px; 35 | } 36 | 37 | .shadow { 38 | box-shadow: 0px 0px 4px 4px @accent_color; 39 | } 40 | 41 | .pulse { 42 | animation-name: breathing; 43 | animation-duration: 3s; 44 | animation-timing-function: ease-in-out; 45 | animation-iteration-count: infinite; 46 | animation-direction: normal; 47 | animation-delay: 0s; 48 | animation-fill-mode: none; 49 | animation-play-state: running; 50 | } 51 | 52 | @keyframes breathing { 53 | 0% { 54 | transform: scale(1); 55 | } 56 | 57 | 50% { 58 | transform: scale(0.95); 59 | } 60 | 61 | 100% { 62 | transform: scale(1); 63 | } 64 | } 65 | 66 | .groupcolor list { 67 | background-color: alpha(#dddddd, 0.2); 68 | } 69 | 70 | .progress_complete progress { 71 | background-color: @success_color; 72 | } 73 | 74 | .progress_error progress { 75 | background-color: @error_color; 76 | } 77 | 78 | /* Theme Switcher 79 | * Modified from https://gitlab.gnome.org/tijder/blueprintgtk 80 | * Original header below 81 | */ 82 | 83 | /* 84 | * Base on dialect-app 85 | * https://github.com/dialect-app/dialect/blob/c0b7ca0580d4c7cfb32ff7ed0a3a08c06bbe40e0/data/resources/style.css 86 | */ 87 | 88 | .themeswitcher { 89 | margin: 9px; 90 | } 91 | 92 | .themeswitcher checkbutton { 93 | min-height: 44px; 94 | min-width: 44px; 95 | padding: 1px; 96 | background-clip: content-box; 97 | border-radius: 9999px; 98 | box-shadow: inset 0 0 0 1px @borders; 99 | } 100 | 101 | .themeswitcher checkbutton.system:checked, 102 | .themeswitcher checkbutton.light:checked, 103 | .themeswitcher checkbutton.dark:checked { 104 | box-shadow: inset 0 0 0 2px @theme_selected_bg_color; 105 | } 106 | 107 | .themeswitcher checkbutton.system { 108 | background-image: linear-gradient(to bottom right, #fff 49.99%, #202020 50.01%); 109 | } 110 | 111 | .themeswitcher checkbutton.light { 112 | background-color: #fff; 113 | } 114 | 115 | .themeswitcher checkbutton.dark { 116 | background-color: #202020; 117 | } 118 | 119 | .themeswitcher checkbutton radio { 120 | -gtk-icon-source: none; 121 | border: none; 122 | background: none; 123 | box-shadow: none; 124 | min-width: 12px; 125 | min-height: 12px; 126 | transform: translate(27px, 14px); 127 | padding: 2px; 128 | } 129 | 130 | .themeswitcher checkbutton.theme-selector radio:checked { 131 | -gtk-icon-source: -gtk-icontheme("object-select-symbolic"); 132 | background-color: @theme_selected_bg_color; 133 | color: @theme_selected_fg_color; 134 | } -------------------------------------------------------------------------------- /pypi-dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pypi-dependencies", 3 | "buildsystem": "simple", 4 | "build-commands": [], 5 | "modules": [ 6 | { 7 | "name": "python3-Pillow", 8 | "buildsystem": "simple", 9 | "build-commands": [ 10 | "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"Pillow\" --no-build-isolation" 11 | ], 12 | "sources": [ 13 | { 14 | "type": "file", 15 | "url": "https://files.pythonhosted.org/packages/af/cb/bb5c01fcd2a69335b86c22142b2bccfc3464087efb7fd382eee5ffc7fdf7/pillow-11.2.1.tar.gz", 16 | "sha256": "a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6" 17 | } 18 | ] 19 | }, 20 | { 21 | "name": "python3-tmdbsimple", 22 | "buildsystem": "simple", 23 | "build-commands": [ 24 | "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"tmdbsimple\" --no-build-isolation" 25 | ], 26 | "sources": [ 27 | { 28 | "type": "file", 29 | "url": "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", 30 | "sha256": "30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3" 31 | }, 32 | { 33 | "type": "file", 34 | "url": "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", 35 | "sha256": "5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63" 36 | }, 37 | { 38 | "type": "file", 39 | "url": "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", 40 | "sha256": "946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" 41 | }, 42 | { 43 | "type": "file", 44 | "url": "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", 45 | "sha256": "70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" 46 | }, 47 | { 48 | "type": "file", 49 | "url": "https://files.pythonhosted.org/packages/6c/dd/ade05d202db728b23e54aa0959622d090776023917e7308c1b2469a07b76/tmdbsimple-2.9.1-py3-none-any.whl", 50 | "sha256": "b52387c667bad1dccf5f776a576a971622a111fc08b7f731e360fabcc9860616" 51 | }, 52 | { 53 | "type": "file", 54 | "url": "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", 55 | "sha256": "4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813" 56 | } 57 | ] 58 | } 59 | ] 60 | } -------------------------------------------------------------------------------- /data/icons/symbolic/series-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 82 | -------------------------------------------------------------------------------- /src/ui/widgets/poster_button.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | using Gtk 4.0; 6 | using Adw 1; 7 | 8 | template $PosterButton: Box { 9 | 10 | orientation: vertical; 11 | halign: center; 12 | valign: start; 13 | 14 | map => $_on_map(); 15 | 16 | Adw.Clamp { 17 | maximum-size: 200; 18 | 19 | Button _poster_btn { 20 | 21 | clicked => $_on_poster_btn_clicked(); 22 | 23 | styles ["flat"] 24 | overflow: hidden; 25 | 26 | accessibility { 27 | labelled-by: _title_lbl; 28 | } 29 | 30 | Box { 31 | orientation: vertical; 32 | Overlay _poster_box { 33 | halign: center; 34 | 35 | [overlay] 36 | Adw.Spinner _spinner { 37 | height-request: 32; 38 | valign: center; 39 | } 40 | 41 | [overlay] 42 | Box _new_release_badge { 43 | margin-top: 6; 44 | margin-end: 6; 45 | orientation: vertical; 46 | visible: false; 47 | tooltip-text:_("The series has a new release"); 48 | 49 | Overlay { 50 | halign: end; 51 | valign: start; 52 | 53 | Image{ 54 | icon-name: "star-large-symbolic"; 55 | icon-size: large; 56 | 57 | } 58 | } 59 | } 60 | 61 | [overlay] 62 | Box _soon_release_badge { 63 | margin-top: 6; 64 | margin-end: 6; 65 | orientation: vertical; 66 | visible: false; 67 | tooltip-text:_("The series has a new release soon"); 68 | Overlay { 69 | halign: end; 70 | valign: start; 71 | 72 | Image{ 73 | icon-name: "hourglass-symbolic"; 74 | icon-size: large; 75 | } 76 | } 77 | } 78 | 79 | [overlay] 80 | Box _watched_badge { 81 | margin-top: 6; 82 | margin-end: 6; 83 | orientation: vertical; 84 | visible: false; 85 | tooltip-text:_("This title is marked as watched"); 86 | 87 | Overlay { 88 | halign: end; 89 | valign: start; 90 | 91 | Image{ 92 | icon-name: "check-round-outline-symbolic"; 93 | icon-size: large; 94 | 95 | } 96 | } 97 | } 98 | 99 | Picture _picture { 100 | width-request: 150; 101 | height-request: 225; 102 | content-fit: fill; 103 | 104 | styles ["poster"] 105 | } 106 | } 107 | 108 | Box { 109 | orientation: vertical; 110 | spacing: 3; 111 | 112 | Label _title_lbl { 113 | ellipsize: end; 114 | hexpand: true; 115 | halign: start; 116 | margin-top: 6; 117 | label: bind template.title; 118 | lines: 2; 119 | wrap: true; 120 | } 121 | 122 | Box { 123 | orientation: horizontal; 124 | spacing: 6; 125 | 126 | Label _year_lbl { 127 | label: bind template.year; 128 | halign: start; 129 | styles ["caption", "chip"] 130 | } 131 | 132 | Label _status_lbl { 133 | label: bind template.status; 134 | halign: start; 135 | styles ["caption", "chip"] 136 | } 137 | 138 | Label _watched_lbl { 139 | halign: start; 140 | visible: false; 141 | label: _("Watched"); 142 | styles ["caption", "chip"] 143 | } 144 | } 145 | } 146 | } 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/widgets/background_activity_row.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from gi.repository import Adw, GObject, Gtk 6 | 7 | from .. import shared # type: ignore 8 | 9 | 10 | @Gtk.Template(resource_path=shared.PREFIX + '/ui/widgets/background_activity_row.ui') 11 | class BackgroundActivityRow(Adw.Bin): 12 | """ 13 | This class represents a row in the BackgroundIndicator popover. 14 | 15 | Properties: 16 | title (str): a title 17 | activity_type (str): an activity type, name as in ActivityType 18 | completed (bool): indicates if the activity is completed 19 | 20 | Methods: 21 | None 22 | 23 | Signals: 24 | None 25 | """ 26 | 27 | __gtype_name__ = 'BackgroundActivityRow' 28 | 29 | title = GObject.Property(type=str, default='') 30 | activity_type = GObject.Property(type=str, default='') 31 | completed = GObject.Property(type=bool, default=False) 32 | has_error = GObject.Property(type=bool, default=False) 33 | 34 | _icon = Gtk.Template.Child() 35 | _progress_bar = Gtk.Template.Child() 36 | 37 | def __init__(self): 38 | super().__init__() 39 | 40 | self.connect('notify::completed', self._on_complete) 41 | 42 | @Gtk.Template.Callback('_on_map') 43 | def _on_map(self, user_data: object | None) -> None: 44 | """ 45 | Callback for "map" signal. 46 | Sets the icon based on the completion status and activity type, and starts the progress bar. 47 | 48 | Args: 49 | user_data (object or None): additional data passed to the callback 50 | 51 | Returns: 52 | None 53 | """ 54 | 55 | if not self.completed: 56 | match self.activity_type: 57 | case 'ADD': 58 | self._icon.set_from_icon_name('plus') 59 | case 'REMOVE': 60 | self._icon.set_from_icon_name('user-trash-symbolic') 61 | case 'UPDATE': 62 | self._icon.set_from_icon_name('update') 63 | GObject.timeout_add(500, self._on_timeout, None) 64 | else: 65 | if self.has_error: 66 | self._progress_bar.add_css_class('progress_error') 67 | self._icon.set_from_icon_name('warning') 68 | else: 69 | self._progress_bar.add_css_class('progress_complete') 70 | self._icon.set_from_icon_name('check-plain') 71 | self._progress_bar.set_fraction(1) 72 | 73 | def _on_timeout(self, user_data: object | None) -> bool: 74 | """ 75 | Callback for GObject.timeout_add. 76 | Pulses the progress bar. 77 | 78 | Args: 79 | user_data (object or None): additional data passed to the callback 80 | 81 | Returns: 82 | True if the timeout should be called again, False otherwise 83 | """ 84 | 85 | if not self.completed: 86 | self._progress_bar.pulse() 87 | return True 88 | else: 89 | return False 90 | 91 | def _on_complete(self, pspec: GObject.ParamSpec, user_data: object | None) -> None: 92 | """ 93 | Callback for "notify::completed" signal. 94 | Sets the icon and progres bar to show a completed status, updates the background indicator. 95 | 96 | Args: 97 | pspec (GObject.ParamSpec): The GParamSpec of the property which changed 98 | user_data (object or None): additional data passed to the callback 99 | 100 | Returns: 101 | None 102 | """ 103 | 104 | self._icon.set_from_icon_name('check-plain') 105 | self._progress_bar.set_fraction(1) 106 | 107 | if self.get_ancestor(Adw.ApplicationWindow): 108 | self.get_ancestor(Adw.ApplicationWindow).activate_action( 109 | 'win.update-backgroud-indicator') 110 | -------------------------------------------------------------------------------- /src/pages/edit_episode_page.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from gi.repository import Adw, GObject, Gtk 6 | 7 | from .. import shared # type: ignore 8 | from ..models.episode_model import EpisodeModel 9 | 10 | 11 | @Gtk.Template(resource_path=shared.PREFIX + '/ui/pages/edit_episode_page.ui') 12 | class EditEpisodeNavigationPage(Adw.NavigationPage): 13 | """ 14 | This class represents the 'edit episode' NavigationPane. 15 | 16 | Properties: 17 | None 18 | 19 | Methods: 20 | None 21 | 22 | Signals: 23 | edit-saved (title: str, episode_number: int, runtime: int, overview: str, still_uri: str): emited when the user clicks the save button 24 | """ 25 | 26 | __gtype_name__ = 'EditEpisodeNavigationPage' 27 | 28 | _still = Gtk.Template.Child() 29 | _episode_spin_row = Gtk.Template.Child() 30 | _title_entry = Gtk.Template.Child() 31 | _runtime_spin_row = Gtk.Template.Child() 32 | _overview_text = Gtk.Template.Child() 33 | 34 | __gsignals__ = { 35 | 'edit-saved': (GObject.SIGNAL_RUN_LAST, None, (str, int, int, str, str, bool,)), 36 | } 37 | 38 | def __init__(self, 39 | title: str = '', 40 | episode_number: int = 0, 41 | runtime: int = 0, 42 | overview: str = '', 43 | still_uri: str = f'resource://{shared.PREFIX}/blank_still.jpg', 44 | watched: bool = False): 45 | 46 | super().__init__() 47 | 48 | self._title = title 49 | self._episode_number = episode_number 50 | self._runtime = runtime 51 | self._overview = overview 52 | self._still_uri = still_uri 53 | self._watched = watched 54 | 55 | @Gtk.Template.Callback('_on_map') 56 | def _on_map(self, user_data: object | None) -> None: 57 | """ 58 | Callback for "map" signal. 59 | Sets the fields/still image to the provided values 60 | 61 | Args: 62 | user_data (object or None): user data passed to the callback. 63 | 64 | Returns: 65 | None 66 | """ 67 | 68 | self._overview_text.remove_css_class('view') 69 | 70 | self._title_entry.set_text(self._title) 71 | self._runtime_spin_row.set_value(self._runtime) 72 | self._still.set_blank_image(f'resource://{shared.PREFIX}/blank_still.jpg') 73 | if self._still_uri.startswith('file'): 74 | self._still.set_image(self._still_uri) 75 | self._overview_text.get_buffer().set_text(self._overview, -1) 76 | self._episode_spin_row.set_value(self._episode_number) 77 | 78 | self._title_entry.grab_focus() 79 | 80 | @Gtk.Template.Callback('_enable_save') 81 | def _enable_save(self, source: Gtk.Widget, title: str, episode_number: int) -> bool: 82 | """ 83 | Closure to determine if the 'save' button should be enabled or not. 84 | 85 | Args: 86 | source (Gtk.Widget): caller widget 87 | title (str): title text 88 | episode_number (int): episode number 89 | 90 | Returns: 91 | bool 92 | """ 93 | 94 | return True if title and episode_number > 0 else False 95 | 96 | @Gtk.Template.Callback('_on_save_btn_clicked') 97 | def _on_save_btn_clicked(self, user_data: object | None) -> None: 98 | """ 99 | Callback for the "clicked" signal. 100 | Emits the "edit-saved" signal and pops the NavigationPage. 101 | 102 | Args: 103 | user_data (object or None): user data passed to the callback. 104 | 105 | Returns: 106 | None 107 | """ 108 | 109 | buffer = self._overview_text.get_buffer() 110 | start_iter = buffer.get_start_iter() 111 | end_iter = buffer.get_end_iter() 112 | 113 | overview = buffer.get_text(start_iter, end_iter, False) 114 | title = self._title_entry.get_text() 115 | episode_number = int(self._episode_spin_row.get_value()) 116 | runtime = int(self._runtime_spin_row.get_value()) 117 | still_uri = self._still.get_uri() 118 | 119 | self.emit('edit-saved', title, episode_number, runtime, overview, still_uri, self._watched) 120 | self.get_ancestor(Adw.NavigationView).pop() 121 | -------------------------------------------------------------------------------- /data/me.iepure.Ticketbooth.gschema.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 1024 15 | Window width 16 | 17 | 18 | 600 19 | Window height 20 | 21 | 22 | false 23 | Window maximized 24 | 25 | 26 | 27 | 28 | 29 | 30 | "movies" 31 | Active tab 32 | 33 | 34 | true 35 | Specifies if the app is run for the first time 36 | 37 | 38 | true 39 | Specifies if the database needs an update 40 | 41 | 42 | false 43 | Specifies if the app downloaded the required data 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | "added-date-new" 57 | View sorting style 58 | 59 | 60 | true 61 | Separate watched content from unwatched 62 | 63 | 64 | false 65 | Hide watched content 66 | 67 | 68 | 69 | 70 | false 71 | Search bar visibility 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | "title" 82 | Search mode 83 | 84 | 85 | '' 86 | Search query 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | "auto" 98 | App color scheme 99 | 100 | 101 | "en" 102 | Language used by TMDB results 103 | 104 | 105 | false 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | "week" 115 | Frequency to check for new data on TMDB 116 | 117 | 118 | "1970-01-01" 119 | Last autoupdate date 120 | 121 | 122 | "1970-01-01 00:00" 123 | Last notification autoupdate date 124 | 125 | 126 | true 127 | Clear cache on exit 128 | 129 | 130 | false 131 | Use the user's TMDB key 132 | 133 | 134 | '' 135 | User's TMDB key 136 | 137 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /src/ui/preferences.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2022 - 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | using Gtk 4.0; 6 | using Adw 1; 7 | 8 | template $PreferencesDialog: Adw.PreferencesDialog { 9 | search-enabled: false; 10 | 11 | map => $_on_map(); 12 | 13 | Adw.PreferencesPage { 14 | name: "preferences"; 15 | title: _("Preferences"); 16 | 17 | Adw.PreferencesGroup _download_group { 18 | title: C_("preferences", "Optional Download"); 19 | description: C_("preferences", "For a complete experience, a download of 15 KB is required. The initial setup could not retrieve the data automatically and thus offline mode has been activated. It will remain active until the setup is completed."); 20 | 21 | Adw.ActionRow _download_row { 22 | // TRANSLATORS: When clicked, it completes the initial setup by downloading the optional data. 23 | title: C_("preferences", "Complete Setup"); 24 | activated => $_on_download_activate(); 25 | activatable: true; 26 | 27 | Image { 28 | icon-name: "right"; 29 | } 30 | } 31 | } 32 | 33 | Adw.PreferencesGroup _offline_group { 34 | title: C_("preferences", "Offline Mode"); 35 | description: C_("preferences", "Ticket Booth can work entirely offline. If you choose to run in this mode, some features that require the Internet and/or access to third party APIs will not be available."); 36 | 37 | Adw.SwitchRow _offline_switch { 38 | title: C_("preferences", "Enable Offline Mode"); 39 | } 40 | } 41 | 42 | Adw.PreferencesGroup _tmdb_group { 43 | title: C_("preferences", "The Movie Database (TMDB)"); 44 | description: C_("preferences", "TMDB provides localized metadata for most content. Ticket Booth will download it in your prefered language, selectable below. In case it is not available, it will fallback to English US and then to the content's original language. If neither are available, it will result in a blank string. Please consider contributing to TMDB. Additionally, an automatic update is performed on a frequency of your choosing."); 45 | 46 | Adw.ComboRow _language_comborow { 47 | title: C_("preferences", "TMDB Results Language"); 48 | model: StringList _language_model {}; 49 | } 50 | 51 | Adw.ComboRow _update_freq_comborow { 52 | title: C_("preferences", "Update Frequency"); 53 | subtitle: C_("preferences", "Restart Ticket Booth after changing"); 54 | model: StringList { 55 | strings[ 56 | C_("preferences", "Never"), 57 | C_("preferences", "Daily"), 58 | C_("preferences", "Weekly"), 59 | C_("preferences", "Monthly") 60 | ] 61 | }; 62 | } 63 | 64 | Adw.SwitchRow _use_own_key_switch { 65 | title: C_("preferences", "Use Your API Key"); 66 | subtitle: C_("preferences", "Register yours here"); 67 | } 68 | 69 | Adw.EntryRow _own_key_entryrow { 70 | title: C_("preferences", "Your API key"); 71 | visible: bind _use_own_key_switch.active; 72 | 73 | [suffix] 74 | Button _check_own_key_button { 75 | label: C_("preferences", "Save Key"); 76 | valign: center; 77 | 78 | clicked => $_on_check_own_key_button_clicked(); 79 | } 80 | 81 | changed => $_on_own_key_changed(); 82 | } 83 | } 84 | 85 | Adw.PreferencesGroup _housekeeping_group { 86 | title: C_("preferences", "Housekeeping"); 87 | 88 | Adw.PreferencesGroup { 89 | Adw.SwitchRow _exit_cache_switch { 90 | title: C_("preferences", "Clear Cache on Exit"); 91 | } 92 | 93 | Adw.ActionRow _cache_row { 94 | title: C_("preferences", "Clear Cached Search Data"); 95 | activated => $_on_clear_cache_activate(); 96 | activatable: true; 97 | 98 | Image { 99 | icon-name: "right"; 100 | } 101 | } 102 | 103 | Adw.ActionRow _data_row { 104 | title: C_("preferences", "Clear Data"); 105 | activated => $_on_clear_activate(); 106 | activatable: true; 107 | 108 | Image { 109 | icon-name: "right"; 110 | } 111 | } 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/models/episode_model.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import glob 6 | import os 7 | import re 8 | 9 | import requests 10 | from gi.repository import GObject 11 | from PIL import Image 12 | 13 | from .. import shared # type: ignore 14 | 15 | 16 | class EpisodeModel(GObject.GObject): 17 | """ 18 | This class represents an episode object stored in the db. 19 | 20 | Properties: 21 | id (str): episode id 22 | number (int): episode number in its season 23 | overview (str): episode overview 24 | runtime (int): episode runtime in minutes 25 | season_number (int): season the episode belongs to 26 | show_id (int): id of the show the episode belongs to 27 | still_path (str): uri of the episode still 28 | title (str): episode title 29 | watched (bool): whether the episode has been watched or not 30 | 31 | Methods: 32 | None 33 | 34 | Signals: 35 | None 36 | """ 37 | 38 | __gtype_name__ = 'EpisodeModel' 39 | 40 | id = GObject.Property(type=str, default='') 41 | number = GObject.Property(type=int, default=0) 42 | overview = GObject.Property(type=str, default='') 43 | runtime = GObject.Property(type=int, default=0) 44 | season_number = GObject.Property(type=int, default=0) 45 | show_id = GObject.Property(type=str, default='') 46 | still_path = GObject.Property(type=str, default='') 47 | title = GObject.Property(type=str, default='') 48 | watched = GObject.Property(type=bool, default=False) 49 | 50 | def __init__(self, d=None, t=None): 51 | super().__init__() 52 | 53 | if d is not None: 54 | self.id = d['id'] 55 | self.number = d['episode_number'] 56 | self.overview = re.sub(r'\s{2}', ' ', d['overview']) 57 | self.runtime = d['runtime'] if d['runtime'] else 0 58 | self.season_number = d['season_number'] 59 | self.show_id = d['show_id'] 60 | self.still_path = self._download_still(d['still_path']) 61 | self.title = d['name'] 62 | self.watched = False 63 | else: 64 | self.id = t[0] # type: ignore 65 | self.number = t[1] # type: ignore 66 | self.overview = t[2] # type: ignore 67 | self.runtime = t[3] # type: ignore 68 | self.season_number = t[4] # type: ignore 69 | self.show_id = t[5] # type: ignore 70 | self.still_path = t[6] # type: ignore 71 | self.title = t[7] # type: ignore 72 | self.watched = t[8] # type: ignore 73 | 74 | def _download_still(self, path: str) -> str: 75 | """ 76 | Returns the uri of the still image on the local filesystem, downloading if necessary. 77 | 78 | Args: 79 | path (str): path to dowload from 80 | 81 | Returns: 82 | str with the uri of the still image 83 | """ 84 | 85 | if not path: 86 | return f'resource://{shared.PREFIX}/blank_still.jpg' 87 | 88 | if not os.path.exists(f'{shared.series_dir}/{self.show_id}/{self.season_number}'): 89 | os.makedirs( 90 | f'{shared.series_dir}/{self.show_id}/{self.season_number}') 91 | 92 | files = glob.glob( 93 | f'{path[1:-4]}.jpg', root_dir=f'{shared.series_dir}/{self.show_id}/{self.season_number}') 94 | if files: 95 | return f'file://{shared.series_dir}/{self.show_id}/{self.season_number}/{files[0]}' 96 | 97 | url = f'https://image.tmdb.org/t/p/w500{path}' 98 | try: 99 | r = requests.get(url) 100 | if r.status_code == 200: 101 | with open(f'{shared.series_dir}/{self.show_id}/{self.season_number}{path}', 'wb') as f: 102 | f.write(r.content) 103 | 104 | with Image.open(f'{shared.series_dir}/{self.show_id}/{self.season_number}{path}') as img: 105 | img = img.resize((500, 281)) 106 | img.save( 107 | f'{shared.series_dir}/{self.show_id}/{self.season_number}{path}', 'JPEG') 108 | return f'file://{shared.series_dir}/{self.show_id}/{self.season_number}{path}' 109 | else: 110 | return f'resource://{shared.PREFIX}/blank_still.jpg' 111 | except (requests.exceptions.ConnectionError, requests.exceptions.SSLError): 112 | return f'resource://{shared.PREFIX}/blank_still.jpg' 113 | -------------------------------------------------------------------------------- /src/widgets/poster_button.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from gi.repository import Gio, GObject, Gtk 6 | from pathlib import Path 7 | from .. import shared # type: ignore 8 | from ..models.movie_model import MovieModel 9 | from ..models.series_model import SeriesModel 10 | 11 | 12 | @Gtk.Template(resource_path=shared.PREFIX + '/ui/widgets/poster_button.ui') 13 | class PosterButton(Gtk.Box): 14 | """ 15 | Widget shown in the main view with poster, title, and release year. 16 | 17 | Properties: 18 | title (str): content's title 19 | year (str): content's release year 20 | tmdb_id (str): content's tmdb id 21 | poster_path (str): content's poster uri 22 | 23 | Methods: 24 | None 25 | 26 | Signals: 27 | clicked(content: MovieModel or SeriesModel): emited when the user clicks on the widget 28 | """ 29 | 30 | __gtype_name__ = 'PosterButton' 31 | 32 | _poster_box = Gtk.Template.Child() 33 | _picture = Gtk.Template.Child() 34 | _spinner = Gtk.Template.Child() 35 | _year_lbl = Gtk.Template.Child() 36 | _status_lbl = Gtk.Template.Child() 37 | _watched_lbl = Gtk.Template.Child() 38 | _new_release_badge = Gtk.Template.Child() 39 | _soon_release_badge = Gtk.Template.Child() 40 | _watched_badge = Gtk.Template.Child() 41 | 42 | 43 | # Properties 44 | title = GObject.Property(type=str, default='') 45 | year = GObject.Property(type=str, default='') 46 | status = GObject.Property(type=str, default='') 47 | tmdb_id = GObject.Property(type=str, default='') 48 | poster_path = GObject.Property(type=str, default='') 49 | watched = GObject.Property(type=bool, default=False) 50 | content = GObject.Property(type=object, default=None) 51 | 52 | __gsignals__ = { 53 | 'clicked': (GObject.SIGNAL_RUN_FIRST, None, (object,)), 54 | } 55 | 56 | def __init__(self, content: MovieModel | SeriesModel): 57 | super().__init__() 58 | self.activate_notification = content.activate_notification 59 | self.title = content.title 60 | self.badge_color_light = content.color 61 | self.year = content.release_date[0:4] if content.release_date else None 62 | self.tmdb_id = content.id 63 | self.poster_path = content.poster_path 64 | self.watched = content.watched 65 | self.status = content.status 66 | self.new_release = content.new_release 67 | self.soon_release = content.soon_release 68 | self.recent_change = content.recent_change 69 | self.content = content 70 | 71 | @Gtk.Template.Callback('_on_map') 72 | def _on_map(self, user_data: object | None) -> None: 73 | """ 74 | Callback for the 'map' signal. 75 | Sets images and hides release year label if not present. 76 | 77 | Args: 78 | user_data (object or None): data passed to the callback 79 | 80 | Returns: 81 | None 82 | """ 83 | 84 | 85 | self._picture.set_file(Gio.File.new_for_uri(self.poster_path)) 86 | self._spinner.set_visible(False) 87 | 88 | badge_visible = False 89 | if self.activate_notification: 90 | if self.recent_change: 91 | self._poster_box.add_css_class("pulse") 92 | self._picture.add_css_class("shadow") 93 | 94 | if self.new_release: 95 | self._new_release_badge.set_visible(True) 96 | badge_visible = True 97 | if self.badge_color_light: 98 | self._new_release_badge.add_css_class("light") 99 | else: 100 | self._new_release_badge.add_css_class("dark") 101 | elif self.soon_release: 102 | self._soon_release_badge.set_visible(True) 103 | badge_visible = True 104 | if self.badge_color_light: 105 | self._soon_release_badge.add_css_class("light") 106 | else: 107 | self._soon_release_badge.add_css_class("dark") 108 | 109 | 110 | if not self.year: 111 | self._year_lbl.set_visible(False) 112 | if self.status == '': 113 | self._status_lbl.set_visible(False) 114 | if self.watched and not badge_visible: 115 | self._watched_badge.set_visible(True) 116 | if self.badge_color_light: 117 | self._watched_badge.add_css_class("light") 118 | else: 119 | self._watched_badge.add_css_class("dark") 120 | 121 | @Gtk.Template.Callback('_on_poster_btn_clicked') 122 | def _on_poster_btn_clicked(self, user_data: object | None) -> None: 123 | self.emit('clicked', self.content) 124 | -------------------------------------------------------------------------------- /src/providers/tmdb_provider.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import os 6 | 7 | import tmdbsimple as tmdb 8 | 9 | from .. import shared # type: ignore 10 | 11 | 12 | class TMDBProvider: 13 | """ 14 | This class provides methods to interface with the TMDB API. 15 | 16 | Properties: 17 | None 18 | 19 | Methods: 20 | search(query: str, lang: str or None): Searches the API for the given query 21 | get_languages(): Retrieves all available languages usable with the API 22 | get_movie(id: int, lang: str): Retrieves general information about a movie. 23 | get_serie(id: int, lang: str): Retrieves general information about a tv series. 24 | get_season_episodes(id: int, series:int, lang: str): Retrieves information about the episodes in a season. 25 | """ 26 | 27 | if shared.schema.get_boolean('use-own-tmdb-key'): 28 | tmdb.API_KEY = shared.schema.get_string('own-tmdb-key') 29 | else: 30 | tmdb.API_KEY = os.environ.get('TMDB_KEY') 31 | 32 | def __init__(self): 33 | super().__init__() 34 | 35 | @staticmethod 36 | def search(query: str, lang: str | None = None) -> dict: 37 | """ 38 | Searches the API for the given query. 39 | 40 | Args: 41 | query (str): a query to lookup 42 | lang (str or None): the prefered language for the results (ISO 639-1 format) 43 | 44 | Returns: 45 | dict containg the API result. 46 | """ 47 | 48 | if not lang: 49 | lang = shared.schema.get_string('tmdb-lang') 50 | 51 | return tmdb.Search().multi(query=query, language=lang, include_adult=False) 52 | 53 | @staticmethod 54 | def get_languages() -> dict: 55 | """ 56 | Retrieves all available languages usable with the API 57 | 58 | Args: 59 | None 60 | 61 | Returns: 62 | dict containg the API result. 63 | """ 64 | 65 | return tmdb.Configuration().languages() 66 | 67 | @staticmethod 68 | def get_movie(id: int, lang: str | None = None) -> dict: 69 | """ 70 | Retrieves general information about the movie with the provided id. 71 | 72 | Args: 73 | id (int): id of the movie 74 | lang (str): the prefered language for the results (optional) 75 | 76 | Returns: 77 | dict containg the API result. 78 | """ 79 | if not lang: 80 | lang = shared.schema.get_string('tmdb-lang') 81 | 82 | return tmdb.Movies(id).info(language=lang) 83 | 84 | @staticmethod 85 | def get_serie(id: int, lang: str | None = None) -> dict: 86 | """ 87 | Retrieves general information about the tv series with the provided id. 88 | 89 | Args: 90 | id (int): id of the tv series 91 | lang (str): the prefered language for the results (optional) 92 | 93 | Returns: 94 | dict containg the API result 95 | """ 96 | if not lang: 97 | lang = shared.schema.get_string('tmdb-lang') 98 | 99 | return tmdb.TV(id).info(language=lang) 100 | 101 | @staticmethod 102 | def get_season_episodes(id: int, season: int, lang: str | None = None) -> dict: 103 | """ 104 | Retrieves information about the episodes in a season for the specified tv series. 105 | 106 | Args: 107 | id (int): id of the tv series 108 | season (int): season number 109 | lang (str): the prefered language for the results (optional) 110 | 111 | Returns: 112 | dict containg the API result. 113 | """ 114 | 115 | if not lang: 116 | lang = shared.schema.get_string('tmdb-lang') 117 | 118 | return tmdb.TV_Seasons(id, season).info(language=lang)['episodes'] 119 | 120 | @staticmethod 121 | def set_key(key: str) -> None: 122 | """ 123 | Sets the API in use. 124 | 125 | Args: 126 | key (str): key to use 127 | 128 | Returns: 129 | None 130 | """ 131 | 132 | tmdb.API_KEY = key 133 | 134 | @staticmethod 135 | def get_key() -> str: 136 | """ 137 | Gets the API in use. 138 | 139 | Args: 140 | None 141 | 142 | Returns: 143 | str with the key in use 144 | """ 145 | 146 | return tmdb.API_KEY 147 | 148 | @staticmethod 149 | def get_builtin_key() -> str: 150 | """ 151 | Gets the builtin API key. 152 | 153 | Args: 154 | None 155 | 156 | Returns: 157 | str with the builtin key 158 | """ 159 | 160 | return os.environ.get('TMDB_KEY') # type: ignore 161 | -------------------------------------------------------------------------------- /src/ui/views/content_view.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | using Gtk 4.0; 5 | using Adw 1; 6 | 7 | template $ContentView: Adw.Bin { 8 | 9 | map => $_on_map(); 10 | 11 | Adw.ViewStack _stack { 12 | Adw.ViewStackPage { 13 | name: "empty"; 14 | 15 | child: Adw.StatusPage { 16 | icon-name: bind template.icon-name; 17 | title: _("Your Watchlist Is Empty"); 18 | description: _("Add content with the + button or import from an older watchlist."); 19 | }; 20 | } 21 | 22 | Adw.ViewStackPage { 23 | name: "updating"; 24 | 25 | child: Adw.StatusPage { 26 | child: Box { 27 | orientation: vertical; 28 | 29 | Adw.Spinner { 30 | height-request: 64; 31 | } 32 | 33 | Box { 34 | orientation: vertical; 35 | margin-start: 12; 36 | margin-end: 12; 37 | 38 | Label { 39 | label: "Updating your watchlist…"; 40 | wrap: true; 41 | halign: center; 42 | justify: center; 43 | 44 | styles [ 45 | "title-1" 46 | ] 47 | } 48 | 49 | Label _updating_status_lbl { 50 | halign: center; 51 | justify: center; 52 | wrap: true; 53 | } 54 | } 55 | }; 56 | }; 57 | } 58 | 59 | Adw.ViewStackPage { 60 | name: "loading"; 61 | 62 | child: Adw.StatusPage { 63 | child: Box { 64 | orientation: vertical; 65 | 66 | Adw.Spinner { 67 | height-request: 64; 68 | } 69 | 70 | Box { 71 | orientation: vertical; 72 | margin-start: 12; 73 | margin-end: 12; 74 | 75 | Label _heading_lbl { 76 | label: "Loading your watchlist…"; 77 | wrap: true; 78 | halign: center; 79 | justify: center; 80 | 81 | styles [ 82 | "title-1" 83 | ] 84 | } 85 | 86 | Label _status_lbl { 87 | label: "Get your popcorns ready"; 88 | halign: center; 89 | justify: center; 90 | wrap: true; 91 | } 92 | } 93 | }; 94 | }; 95 | } 96 | 97 | Adw.ViewStackPage { 98 | name: "filled"; 99 | 100 | child: ScrolledWindow _scrolled_window { 101 | Box { 102 | orientation: vertical; 103 | 104 | Box _full_box { 105 | visible: false; 106 | orientation: vertical; 107 | spacing: 12; 108 | halign: start; 109 | 110 | Label _title_lbl { 111 | halign: start; 112 | margin-start: 12; 113 | margin-top: 12; 114 | label: _("Your Watchlist"); 115 | 116 | styles [ 117 | "title-1" 118 | ] 119 | } 120 | 121 | FlowBox _flow_box { 122 | orientation: horizontal; 123 | min-children-per-line: 2; 124 | max-children-per-line: 15; 125 | selection-mode: none; 126 | halign: start; 127 | } 128 | } 129 | 130 | Box _separated_box { 131 | visible: false; 132 | orientation: vertical; 133 | spacing: 12; 134 | halign: start; 135 | 136 | Box _unwatched_box { 137 | orientation: vertical; 138 | 139 | Label { 140 | halign: start; 141 | margin-start: 12; 142 | margin-top: 12; 143 | label: _("Unwatched"); 144 | 145 | styles [ 146 | "title-1" 147 | ] 148 | } 149 | 150 | FlowBox _unwatched_flow_box { 151 | orientation: horizontal; 152 | min-children-per-line: 2; 153 | max-children-per-line: 15; 154 | selection-mode: none; 155 | halign: start; 156 | } 157 | } 158 | 159 | Box _watched_box { 160 | orientation: vertical; 161 | 162 | Label { 163 | halign: start; 164 | margin-start: 12; 165 | margin-top: 12; 166 | label: _("Watched"); 167 | 168 | styles [ 169 | "title-1" 170 | ] 171 | } 172 | 173 | FlowBox _watched_flow_box { 174 | orientation: horizontal; 175 | min-children-per-line: 2; 176 | max-children-per-line: 15; 177 | selection-mode: none; 178 | halign: start; 179 | } 180 | } 181 | } 182 | } 183 | }; 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/ticketbooth.gresource.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | css/style.css 15 | css/style-dark.css 16 | 18 | 19 | 20 | ui/widgets/theme_switcher.ui 21 | ui/widgets/poster_button.ui 22 | ui/widgets/search_result_row.ui 23 | ui/widgets/episode_row.ui 24 | ui/widgets/image_selector.ui 25 | ui/widgets/season_expander.ui 26 | ui/widgets/background_indicator.ui 27 | ui/widgets/background_activity_row.ui 28 | 29 | 30 | ui/dialogs/add_manual.ui 31 | ui/dialogs/add_tmdb.ui 32 | ui/dialogs/edit_season.ui 33 | ui/dialogs/message_dialogs.ui 34 | 35 | 36 | ui/gtk/help-overlay.ui 37 | ui/window.ui 38 | ui/about_dialog.ui 39 | ui/preferences.ui 40 | ui/views/db_update_view.ui 41 | ui/views/first_run_view.ui 42 | ui/views/main_view.ui 43 | ui/views/content_view.ui 44 | 45 | 46 | ui/pages/details_page.ui 47 | ui/pages/edit_episode_page.ui 48 | 49 | 50 | ../data/blank_poster.jpg 51 | ../data/blank_still.jpg 52 | 53 | 54 | 55 | ../data/icons/symbolic/series-symbolic.svg 56 | ../data/icons/symbolic/bell-outline-symbolic.svg 57 | ../data/icons/symbolic/check-round-outline-symbolic.svg 58 | ../data/icons/symbolic/movies-symbolic.svg 59 | ../data/icons/symbolic/network-transmit-receive-symbolic.svg 60 | ../data/icons/symbolic/view-grid-symbolic.svg 61 | ../data/icons/symbolic/view-list-symbolic.svg 62 | ../data/icons/symbolic/loupe-symbolic.svg 63 | ../data/icons/symbolic/plus-symbolic.svg 64 | ../data/icons/symbolic/document-edit-symbolic.svg 65 | ../data/icons/symbolic/user-trash-symbolic.svg 66 | ../data/icons/symbolic/check-plain-symbolic.svg 67 | ../data/icons/symbolic/watchlist-symbolic.svg 68 | ../data/icons/symbolic/star-large-symbolic.svg 69 | ../data/icons/symbolic/hourglass-symbolic.svg 70 | ../data/icons/symbolic/right-symbolic.svg 71 | ../data/icons/symbolic/update-symbolic.svg 72 | ../data/icons/symbolic/warning-symbolic.svg 73 | ../data/icons/symbolic/floppy-symbolic.svg 74 | ../data/icons/symbolic/bell-outline-none-symbolic.svg 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /src/background_queue.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from enum import Enum 6 | from typing import Callable 7 | 8 | from gi.repository import Gio, GLib, GObject 9 | 10 | import logging 11 | 12 | from . import shared # type: ignore 13 | 14 | 15 | class ActivityType(Enum): 16 | """ 17 | Enum for the types of background activities 18 | """ 19 | 20 | ADD = 0 21 | REMOVE = 1 22 | UPDATE = 2 23 | 24 | 25 | class BackgroundActivity(GObject.GObject): 26 | """ 27 | An activity that is run in the background. 28 | 29 | Properties: 30 | title (str): a title 31 | activity_type (str): an activity type, name as in ActivityType 32 | callback (callable): a function to run in the background 33 | completed (bool): indicates if the activity is completed 34 | 35 | Methods: 36 | start(): runs self.callback in a separate thread 37 | end(): marks the activity as completed 38 | 39 | Signals: 40 | None 41 | """ 42 | 43 | __gtype_name__ = 'BackgroundActivity' 44 | 45 | title = GObject.Property(type=str, default='') 46 | activity_type = GObject.Property(type=str, default='') 47 | task_function = GObject.Property(type=object, default=None) 48 | completed = GObject.Property(type=bool, default=False) 49 | has_error = GObject.Property(type=bool, default=False) 50 | 51 | def __init__(self, activity_type: ActivityType, title: str = '', task_function: Callable | None = None): 52 | super().__init__() 53 | self.activity_type = activity_type.name 54 | self.title = title 55 | self.task_function = task_function 56 | self._cancellable = Gio.Cancellable() 57 | 58 | def start(self, on_done: Callable) -> None: 59 | """ 60 | Runs self.callback in a separate thread. The callback must call end() to mark the activity as completed. 61 | 62 | Args: 63 | None 64 | 65 | Returns: 66 | None 67 | """ 68 | 69 | task = Gio.Task.new(self, None, on_done, self._cancellable, self) 70 | task.set_return_on_cancel(True) 71 | task.run_in_thread(self._run_in_thread) 72 | 73 | def _run_in_thread(self, 74 | task: Gio.Task, 75 | source_object: GObject.Object, 76 | task_data: object | None, 77 | cancelable: Gio.Cancellable): 78 | """Callback to run self.task_function in a thread""" 79 | 80 | if task.return_error_if_cancelled(): 81 | return 82 | 83 | outcome = self.task_function(self) # type: ignore 84 | task.return_value(GLib.Variant('b', outcome)) 85 | 86 | def activity_finish(self, result: Gio.AsyncResult, caller: GObject.Object): 87 | """ 88 | Completes the async operation and marks the activity as completed. 89 | 90 | Args: 91 | None 92 | 93 | Returns: 94 | None 95 | """ 96 | 97 | if not Gio.Task.is_valid(result, caller): 98 | return -1 99 | 100 | try: 101 | variant_value = result.propagate_value().value 102 | if isinstance(variant_value, GLib.Variant): 103 | return variant_value.get_boolean() 104 | return variant_value 105 | except GLib.Error as e: 106 | logging.error(f"Error in activity_finish: {e}") 107 | return False 108 | 109 | def end(self) -> None: 110 | """ 111 | Marks the activity as completed. 112 | 113 | Args: 114 | None 115 | 116 | Returns: 117 | None 118 | """ 119 | 120 | self.completed = True 121 | 122 | def error(self) -> None: 123 | """ 124 | Marks the activity with an error. 125 | 126 | Args: 127 | None 128 | 129 | Returns: 130 | None 131 | """ 132 | 133 | self.has_error = True 134 | 135 | 136 | class BackgroundQueue: 137 | """ 138 | A queue of background activities. 139 | 140 | Properties: 141 | None 142 | 143 | Methods: 144 | add(activity: BackgroundActivity): adds an activity to the queue 145 | get_queue(): returns the queue 146 | 147 | Signals: 148 | None 149 | """ 150 | 151 | _queue = Gio.ListStore.new(item_type=BackgroundActivity) 152 | 153 | @staticmethod 154 | def add(activity: BackgroundActivity, on_done: Callable) -> None: 155 | """ 156 | Adds an activity to the queue and starts its execution. 157 | 158 | Args: 159 | activity (BackgroundActivity): the activity to add 160 | 161 | Returns: 162 | None 163 | """ 164 | 165 | BackgroundQueue._queue.append(activity) 166 | activity.start(on_done) 167 | 168 | @staticmethod 169 | def get_queue() -> Gio.ListStore: 170 | """ 171 | Returns the queue 172 | 173 | Args: 174 | None 175 | 176 | Returns: 177 | the queue (a Gio.ListStore) 178 | """ 179 | 180 | return BackgroundQueue._queue 181 | -------------------------------------------------------------------------------- /src/widgets/image_selector.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from gi.repository import Adw, Gio, GLib, GObject, Gtk 6 | 7 | from .. import shared # type: ignore 8 | 9 | 10 | @Gtk.Template(resource_path=shared.PREFIX + '/ui/widgets/image_selector.ui') 11 | class ImageSelector(Adw.Bin): 12 | """ 13 | This class represents the image selector and previewer with options to open a file and, if one is already opened, 14 | to delete the selection. 15 | 16 | Properties: 17 | content-fit (Gtk.ContentFit): content fit for the image 18 | 19 | Methods: 20 | set_file(file: Gio.File): sets the shown file 21 | get_uri(): gets the uri for the shown file 22 | 23 | Signals: 24 | None 25 | """ 26 | 27 | __gtype_name__ = 'ImageSelector' 28 | 29 | content_fit = GObject.Property(type=Gtk.ContentFit, default=Gtk.ContentFit.FILL) 30 | shown_image = GObject.Property(type=str, default=f'resource://{shared.PREFIX}/blank_poster.jpg') 31 | blank_image = GObject.Property(type=str, default=f'resource://{shared.PREFIX}/blank_poster.jpg') 32 | 33 | _poster_picture = Gtk.Template.Child() 34 | _edit_btn = Gtk.Template.Child() 35 | _spinner = Gtk.Template.Child() 36 | _delete_revealer = Gtk.Template.Child() 37 | 38 | def __init__(self): 39 | super().__init__() 40 | 41 | @Gtk.Template.Callback('_on_map') 42 | def _on_map(self, user_data): 43 | self._poster_picture.set_file(Gio.File.new_for_uri(self.shown_image)) 44 | 45 | @Gtk.Template.Callback('_on_edit_btn_clicked') 46 | def _on_edit_btn_clicked(self, user_data: object | None) -> None: 47 | """ 48 | Callback for "clicked" signal. 49 | Setups and shows a file chooser dialog to choose a new image. 50 | 51 | Args: 52 | user_data (object or None): additional data passed to the callback 53 | 54 | Returns: 55 | None 56 | """ 57 | 58 | self._edit_btn.set_sensitive(False) 59 | self._spinner.set_visible(True) 60 | 61 | file_filter_store = Gio.ListStore.new(Gtk.FileFilter) 62 | file_filter = Gtk.FileFilter() 63 | file_filter.add_pixbuf_formats() 64 | file_filter_store.append(file_filter) 65 | 66 | self.dialog = Gtk.FileDialog.new() 67 | self.dialog.set_modal(True) 68 | self.dialog.set_filters(file_filter_store) 69 | self.dialog.open(self.get_ancestor(Gtk.Window), None, self._on_file_open_complete, None) 70 | 71 | def _on_file_open_complete(self, 72 | source: Gtk.Widget, 73 | result: Gio.AsyncResult, 74 | user_data: object | None) -> None: 75 | """ 76 | Callback for the file dialog. 77 | Finishes the file selection and, if successfull, shows the new selected image. 78 | 79 | Args: 80 | source (Gtk.Widget): caller widget 81 | result (Gio.AsyncResult): a Gio.AsyncResult 82 | user_data (object or None): additional data passed to the callback 83 | 84 | Returns: 85 | None 86 | """ 87 | 88 | try: 89 | poster_file = self.dialog.open_finish(result) 90 | except GLib.GError: 91 | poster_file = None 92 | 93 | if poster_file: 94 | self.shown_image = poster_file.get_uri() 95 | self._poster_picture.set_file(poster_file) 96 | self._delete_revealer.set_reveal_child(True) 97 | 98 | self._spinner.set_visible(False) 99 | self._edit_btn.set_sensitive(True) 100 | 101 | @Gtk.Template.Callback('_on_delete_btn_clicked') 102 | def _on_delete_btn_clicked(self, user_data: object | None) -> None: 103 | """ 104 | Callback for "clicked" signal. 105 | Restores the blank image and hides the delete button. 106 | 107 | Args: 108 | user_data (object or None): additional data passed to the callback 109 | 110 | Returns: 111 | None 112 | """ 113 | 114 | self.shown_image = self.blank_image 115 | self._poster_picture.set_file(Gio.File.new_for_uri(self.shown_image)) 116 | self._delete_revealer.set_reveal_child(False) 117 | 118 | def set_blank_image(self, image_uri: str) -> None: 119 | """ 120 | Sets the blank image and shows it. 121 | 122 | Args: 123 | image_uri (str): uri to use 124 | 125 | Returns: 126 | None 127 | """ 128 | 129 | self.blank_image = image_uri 130 | self.shown_image = self.blank_image 131 | self._poster_picture.set_file(Gio.File.new_for_uri(self.shown_image)) 132 | 133 | def set_image(self, image_uri: str) -> None: 134 | """ 135 | Sets the image. 136 | 137 | Args: 138 | image_uri (str): uri to use 139 | 140 | Returns: 141 | None 142 | """ 143 | 144 | self.shown_image = image_uri 145 | self._poster_picture.set_file(Gio.File.new_for_uri(self.shown_image)) 146 | self._delete_revealer.set_reveal_child(True) 147 | 148 | def get_uri(self) -> str: 149 | """ 150 | Returns the shown image uri. 151 | 152 | Args: 153 | None 154 | 155 | Returns: 156 | string with the uri 157 | """ 158 | 159 | return self._poster_picture.get_file().get_uri() 160 | -------------------------------------------------------------------------------- /src/logging/session_file_handler.py: -------------------------------------------------------------------------------- 1 | # session_file_handler.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | import lzma 21 | from io import TextIOWrapper 22 | from logging import StreamHandler 23 | from lzma import FORMAT_XZ, PRESET_DEFAULT 24 | from os import PathLike 25 | from pathlib import Path 26 | from typing import Optional 27 | 28 | from .. import shared # type: ignore 29 | 30 | 31 | class SessionFileHandler(StreamHandler): 32 | """ 33 | A logging handler that writes to a new file on every app restart. 34 | The files are compressed and older sessions logs are kept up to a small limit. 35 | """ 36 | 37 | NUMBER_SUFFIX_POSITION = 1 38 | 39 | backup_count: int 40 | filename: Path 41 | log_file: Optional[TextIOWrapper] = None 42 | 43 | def create_dir(self) -> None: 44 | """Create the log dir if needed""" 45 | self.filename.parent.mkdir(exist_ok=True, parents=True) 46 | 47 | def path_is_logfile(self, path: Path) -> bool: 48 | return path.is_file() and path.name.startswith(self.filename.stem) 49 | 50 | def path_has_number(self, path: Path) -> bool: 51 | try: 52 | int(path.suffixes[self.NUMBER_SUFFIX_POSITION][1:]) 53 | except (ValueError, IndexError): 54 | return False 55 | return True 56 | 57 | def get_path_number(self, path: Path) -> int: 58 | """Get the number extension in the filename as an int""" 59 | suffixes = path.suffixes 60 | number = ( 61 | 0 62 | if not self.path_has_number(path) 63 | else int(suffixes[self.NUMBER_SUFFIX_POSITION][1:]) 64 | ) 65 | return number 66 | 67 | def set_path_number(self, path: Path, number: int) -> str: 68 | """Set or add the number extension in the filename""" 69 | suffixes = path.suffixes 70 | if self.path_has_number(path): 71 | suffixes.pop(self.NUMBER_SUFFIX_POSITION) 72 | suffixes.insert(self.NUMBER_SUFFIX_POSITION, f'.{number}') 73 | stem = path.name.split('.', maxsplit=1)[0] 74 | new_name = stem + ''.join(suffixes) 75 | return new_name 76 | 77 | def file_sort_key(self, path: Path) -> int: 78 | """Key function used to sort files""" 79 | return self.get_path_number(path) if self.path_has_number(path) else 0 80 | 81 | def get_logfiles(self) -> list[Path]: 82 | """Get the log files""" 83 | logfiles = list(filter(self.path_is_logfile, 84 | self.filename.parent.iterdir())) 85 | logfiles.sort(key=self.file_sort_key, reverse=True) 86 | return logfiles 87 | 88 | def rotate_file(self, path: Path) -> None: 89 | """Rotate a file's number suffix and remove it if it's too old""" 90 | 91 | # If uncompressed, compress 92 | if not path.name.endswith('.xz'): 93 | try: 94 | with open(path, 'r', encoding='utf-8') as original_file: 95 | original_data = original_file.read() 96 | except UnicodeDecodeError: 97 | # If the file is corrupted, throw it away 98 | path.unlink() 99 | return 100 | 101 | # Compress the file 102 | compressed_path = path.with_suffix(path.suffix + '.xz') 103 | with lzma.open( 104 | compressed_path, 105 | 'wt', 106 | format=FORMAT_XZ, 107 | preset=PRESET_DEFAULT, 108 | encoding='utf-8', 109 | ) as lzma_file: 110 | lzma_file.write(original_data) 111 | path.unlink() 112 | path = compressed_path 113 | 114 | # Rename with new number suffix 115 | new_number = self.get_path_number(path) + 1 116 | new_path_name = self.set_path_number(path, new_number) 117 | path = path.rename(path.with_name(new_path_name)) 118 | 119 | # Remove older files 120 | if new_number > self.backup_count: 121 | path.unlink() 122 | return 123 | 124 | def rotate(self) -> None: 125 | """Rotate the numbered suffix on the log files and remove old ones""" 126 | for path in self.get_logfiles(): 127 | self.rotate_file(path) 128 | 129 | def __init__(self, filename: PathLike, backup_count: int = 2) -> None: 130 | self.filename = Path(filename) 131 | self.backup_count = backup_count 132 | self.create_dir() 133 | self.rotate() 134 | self.log_file = open(self.filename, 'w', encoding='utf-8') 135 | shared.log_files = self.get_logfiles() 136 | super().__init__(self.log_file) 137 | 138 | def close(self) -> None: 139 | if self.log_file: 140 | self.log_file.close() 141 | super().close() 142 | -------------------------------------------------------------------------------- /src/models/season_model.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import glob 6 | import os 7 | import re 8 | from typing import List 9 | 10 | import requests 11 | from gi.repository import GObject 12 | 13 | import src.providers.local_provider as local 14 | import src.providers.tmdb_provider as tmdb 15 | 16 | from .. import shared # type: ignore 17 | from ..models.episode_model import EpisodeModel 18 | 19 | 20 | class SeasonModel(GObject.GObject): 21 | """ 22 | This class represents a season object stored in the db. 23 | 24 | Properties: 25 | episodes (List[EpisodeModel]): list of episodes in self 26 | episodes_number (int): number of episodes in self 27 | id (str): season id 28 | number (int): season number 29 | overview (str): season overview 30 | poster_path (str): uri of the season's poster 31 | show_id (int): id of the show the searies belongs to 32 | title (str): season title 33 | 34 | Methods: 35 | None 36 | 37 | Signals: 38 | None 39 | """ 40 | 41 | __gtype_name__ = 'SeasonModel' 42 | 43 | episodes = GObject.Property(type=object) 44 | episodes_number = GObject.Property(type=int, default=0) 45 | id = GObject.Property(type=str, default='') 46 | number = GObject.Property(type=int, default=0) 47 | overview = GObject.Property(type=str, default='') 48 | poster_path = GObject.Property(type=str, default='') 49 | show_id = GObject.Property(type=str, default='') 50 | title = GObject.Property(type=str, default='') 51 | 52 | def __eq__(self, other) -> bool: 53 | """ 54 | Custom comparing fuction, overrides '==' operator. 55 | 56 | Args: 57 | other: object to compare to 58 | 59 | Returns: 60 | bool result of the operation 61 | """ 62 | 63 | if type(other) is not SeasonModel: 64 | return False 65 | 66 | if (self.episodes_number == other.episodes_number and 67 | self.id == other.id and 68 | self.number == other.number and 69 | self.overview == other.overview and 70 | self.poster_path == other.poster_path and 71 | self.show_id == other.show_id and 72 | self.title == other.title): 73 | return True 74 | else: 75 | return False 76 | 77 | def __init__(self, show_id: int = 0, d=None, t=None): 78 | super().__init__() 79 | 80 | if d is not None: 81 | self.episodes_number = d['episode_count'] 82 | self.id = d['id'] 83 | self.number = d['season_number'] 84 | self.overview = re.sub(r'\s{2}', ' ', d['overview']) 85 | self.poster_path = self._download_poster(show_id, d['poster_path']) 86 | self.title = d['name'] 87 | self.show_id = show_id 88 | 89 | self.episodes = self._parse_episodes( 90 | tmdb.TMDBProvider.get_season_episodes(show_id, self.number)) 91 | else: 92 | self.episodes_number = t[0] # type: ignore 93 | self.id = t[1] # type: ignore 94 | self.number = t[2] # type: ignore 95 | self.overview = t[3] # type: ignore 96 | self.poster_path = t[4] # type: ignore 97 | self.title = t[5] # type: ignore 98 | self.show_id = t[6] # type: ignore 99 | 100 | if len(t) == 8: # type: ignore 101 | self.episodes = t[7] # type: ignore 102 | else: 103 | self.episodes = local.LocalProvider.get_season_episodes( 104 | self.show_id, self.number) # type: ignore 105 | 106 | def _download_poster(self, show_id: int, path: str) -> str: 107 | """ 108 | Returns the uri of the poster image on the local filesystem, downloading if necessary. 109 | 110 | Args: 111 | path (str): path to dowload from 112 | 113 | Returns: 114 | str with the uri of the poster image 115 | """ 116 | 117 | if not path: 118 | return f'resource://{shared.PREFIX}/blank_poster.jpg' 119 | 120 | if not os.path.exists(f'{shared.series_dir}/{show_id}/{self.number}'): 121 | os.makedirs(f'{shared.series_dir}/{show_id}/{self.number}') 122 | 123 | files = glob.glob( 124 | f'{path[1:-4]}.jpg', root_dir=f'{shared.series_dir}/{show_id}/{self.number}') 125 | if files: 126 | return f'file://{shared.series_dir}/{show_id}/{self.number}/{files[0]}' 127 | 128 | url = f'https://image.tmdb.org/t/p/w500{path}' 129 | try: 130 | r = requests.get(url) 131 | if r.status_code == 200: 132 | with open(f'{shared.series_dir}/{show_id}/{self.number}{path}', 'wb') as f: 133 | f.write(r.content) 134 | return f'file://{shared.series_dir}/{show_id}/{self.number}{path}' 135 | else: 136 | return f'resource://{shared.PREFIX}/blank_poster.jpg' 137 | except (requests.exceptions.ConnectionError, requests.exceptions.SSLError): 138 | return f'resource://{shared.PREFIX}/blank_poster.jpg' 139 | 140 | def _parse_episodes(self, episodes: dict) -> List[EpisodeModel]: 141 | """ 142 | Parses episode data comming from tmdb into a list of EpisodeModels. 143 | 144 | Args: 145 | episodes (dict): dict from the api 146 | 147 | Returns: 148 | List of EpisodeModels 149 | """ 150 | 151 | episode_list = [] 152 | 153 | for episode in episodes: 154 | episode_list.append(EpisodeModel(d=episode)) 155 | return episode_list 156 | -------------------------------------------------------------------------------- /data/me.iepure.Ticketbooth.metainfo.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | @app_id@ 11 | CC0-1.0 12 | GPL-3.0-or-later 13 | Ticket Booth 14 | @app_id@.desktop 15 | Keep track of your favorite shows 16 | 17 | Alessandro Iepure 18 | 19 | 20 |

Ticket Booth allows you to build your watchlist of movies and TV Shows, keep track of 21 | watched titles, and find information about the latest releases.

22 |

Ticket Booth does not allow you to watch or download content. This app uses the TMDB API 23 | but is not endorsed or certified by TMDB.

24 |
25 | https://github.com/aleiepure/ticketbooth 26 | https://github.com/aleiepure/ticketbooth/issues 27 | https://github.com/aleiepure/ticketbooth 28 | https://github.com/aleiepure/ticketbooth/blob/main/CONTRIBUTING.md 29 | 30 | #e28dad 31 | #9e4768 32 | 33 | 34 | keyboard 35 | pointing 36 | touch 37 | 38 | 39 | 360 40 | 41 | 42 | 43 | https://raw.githubusercontent.com/aleiepure/ticketbooth/main/data/appstream/new1.png 44 | Movie Library 45 | 46 | 47 | https://raw.githubusercontent.com/aleiepure/ticketbooth/main/data/appstream/new2.png 48 | Movie Details 49 | 50 | 51 | https://raw.githubusercontent.com/aleiepure/ticketbooth/main/data/appstream/new3.png 52 | TV Series Library 53 | 54 | 55 | https://raw.githubusercontent.com/aleiepure/ticketbooth/main/data/appstream/new4.png 56 | TV Series Details 57 | 58 | 59 | me.iepure.ticketbooth 60 | 61 | 62 | 63 | 64 |

What's Changed

65 |
    66 |
  • Added search functionality for notes, overview, and by ID
  • 67 |
  • Resolved issue with background stretching during window resizing
  • 68 |
  • Sorting now functions correctly when separating watched and unwatched content
  • 69 |
  • Improved notification toggle appearance
  • 70 |
  • Dialogs now appear after completing import or export
  • 71 |
  • Updated to GNOME platform 48
  • 72 |
  • UI uses new libadwaita 1.7 widgets
  • 73 |
  • Updated translations
  • 74 |
75 |
76 |
77 | 78 | 79 |

What's Changed

80 |
    81 |
  • Fixed a bug that prevented deleting content from your library (@farao)
  • 82 |
  • Fixed a bug that prevented adding content on fresh installs without restarting the app first (@gnesterif)
  • 83 |
  • Updated translations
  • 84 |
85 |

A huge thanks to all contributors and translators for making this release possible!

86 |
87 |
88 | 89 | 90 |

What's Changed

91 |
    92 |
  • Revamped UI with updated widgets, refined theming (@gnesterif), and a fully responsive design for use on smaller devices
  • 93 |
  • Stay in the loop with notifications for new releases and updates (thanks to @gnesterif)
  • 94 |
  • Find what you're looking by searching by title or genre
  • 95 |
  • Keep your library organized by automatically separating watched and unwatched content
  • 96 |
  • Take full control of your library with import and export functions
  • 97 |
  • Personalize your experience by adding custom notes to your movies and shows
  • 98 |
  • Updated translations
  • 99 |
100 |

A huge thanks to all contributors and translators for making this release possible!

101 |
102 |
103 | 104 | 105 |

Fixed issue that prevented application start with version 1.0.3

106 |
107 |
108 | 109 | 110 |

Stability improvements: fixed high peaks utilization and crashing.

111 |
112 |
113 | 114 | 115 |

What's Changed

116 |
    117 |
  • Stability improvements
  • 118 |
  • Fix spelling errors by @librizzia
  • 119 |
  • Translations update
  • 120 |
121 |
122 |
123 | 124 | 125 |

Fixes issue that prevented the addition of movies with large budgets and revenues.

126 |
127 |
128 | 129 | 130 |

First release

131 |
132 |
133 |
134 |
135 | -------------------------------------------------------------------------------- /src/widgets/season_expander.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from gettext import gettext as _ 6 | from gettext import pgettext as C_ 7 | from typing import List 8 | 9 | from gi.repository import Adw, Gio, GObject, Gtk 10 | 11 | import src.dialogs.add_manual_dialog as dialog 12 | 13 | from .. import shared # type: ignore 14 | from ..dialogs.edit_season_dialog import EditSeasonDialog 15 | from ..widgets.episode_row import EpisodeRow 16 | 17 | 18 | @Gtk.Template(resource_path=shared.PREFIX + '/ui/widgets/season_expander.ui') 19 | class SeasonExpander(Adw.ExpanderRow): 20 | """ 21 | This class represents a season in the manual add window. 22 | 23 | Properties: 24 | season_title (str): season title 25 | poster_uri (str): season poster uri 26 | 27 | Methods: 28 | None 29 | 30 | Signals: 31 | None 32 | """ 33 | 34 | __gtype_name__ = 'SeasonExpander' 35 | 36 | season_title = GObject.Property(type=str) 37 | poster_uri = GObject.Property(type=str) 38 | episodes = GObject.Property(type=object) 39 | 40 | _poster = Gtk.Template.Child() 41 | 42 | def __init__(self, 43 | season_title: str = _('Season'), 44 | poster_uri: str = f'resource://{shared.PREFIX}/blank_poster.jpg', 45 | episodes: List | None = None): 46 | 47 | super().__init__() 48 | 49 | self.season_title = season_title 50 | self.poster_uri = poster_uri 51 | self.episodes = episodes if episodes else [] 52 | 53 | @Gtk.Template.Callback('_on_map') 54 | def _on_map(self, user_data: object | None) -> None: 55 | """ 56 | Callback for "map" signal. 57 | Sets the poster image and populates the episode data. 58 | 59 | Args: 60 | user_data (object or None): user data passed to the callback. 61 | 62 | Returns: 63 | None 64 | """ 65 | 66 | self._poster.set_file(Gio.File.new_for_uri(self.poster_uri)) 67 | 68 | for episode in self.episodes: 69 | self.add_row(EpisodeRow(title=episode[0], 70 | episode_number=episode[1], 71 | runtime=episode[2], 72 | overview=episode[3], 73 | still_uri=episode[4], 74 | watched=episode[5], 75 | show_controls=False) 76 | ) 77 | 78 | @Gtk.Template.Callback('_on_edit_btn_clicked') 79 | def _on_edit_btn_clicked(self, user_data: object | None) -> None: 80 | """ 81 | Callback for "clicked" signal. 82 | Shows the "edit season" window. 83 | 84 | Args: 85 | user_data (object or None): user data passed to the callback. 86 | 87 | Returns: 88 | None 89 | """ 90 | 91 | dialog = EditSeasonDialog(self.get_ancestor(Gtk.Window), title=self.season_title, 92 | poster_uri=self.poster_uri, episodes=self.episodes) 93 | dialog.connect('edit-saved', self._on_edit_saved) 94 | dialog.present() 95 | 96 | def _on_edit_saved(self, 97 | source: Gtk.Widget, 98 | title: str, 99 | poster_uri: str, 100 | episodes: List[tuple]) -> None: 101 | """ 102 | Callback for "edit-saved" signal. 103 | Appends the recieved data as a tuple in the seasons list after removing the changed one and updates the ui. 104 | 105 | Args: 106 | source (Gtk.Widget): caller widget 107 | title (str): season title 108 | poster_uri (str): season poster uri 109 | episodes (List[tuple]): episodes in season 110 | 111 | Returns: 112 | None 113 | """ 114 | 115 | parent_dialog = self.get_ancestor(dialog.AddManualDialog) 116 | 117 | old_season = parent_dialog.get_season(self.season_title, 118 | self.poster_uri, 119 | self.episodes) 120 | parent_dialog.seasons.remove(old_season) 121 | 122 | parent_dialog.seasons.append((title, poster_uri, episodes)) 123 | 124 | parent_dialog.update_seasons_ui() 125 | 126 | @Gtk.Template.Callback('_on_delete_btn_clicked') 127 | def _on_delete_btn_clicked(self, user_data: object | None) -> None: 128 | """ 129 | Callback for the "clicked" signal. 130 | Asks the user for a confirmation after a delete request. 131 | 132 | Args: 133 | user_data (object or None): additional data passed to the callback 134 | 135 | Returns: 136 | None 137 | """ 138 | 139 | # TRANSLATORS: {title} is the showed content's title 140 | dialog = Adw.AlertDialog.new( 141 | heading=C_('alert dialog heading', 'Delete {title}?').format( 142 | title=self.season_title), 143 | body=C_('alert dialog body', 144 | 'This season contains unsaved metadata.') 145 | ) 146 | dialog.add_response('cancel', C_('alert dialog action', '_Cancel')) 147 | dialog.add_response('delete', C_('alert dialog action', '_Delete')) 148 | dialog.set_default_response('delete') 149 | dialog.set_close_response('cancel') 150 | dialog.set_response_appearance( 151 | 'delete', Adw.ResponseAppearance.DESTRUCTIVE) 152 | dialog.choose(self, None, self._on_alert_dialog_choose, None) 153 | 154 | def _on_alert_dialog_choose(self, source: GObject.Object | None, result: Gio.AsyncResult, user_data: object | None) -> None: 155 | """ 156 | Callback for the alert dialog. 157 | Finishes the async operation and retrieves the user response. If the later is positive, delete the content from the db. 158 | 159 | Args: 160 | source (Gtk.Widget): object that started the async operation 161 | result (Gio.AsyncResult): a Gio.AsyncResult 162 | user_data (object or None): additional data passed to the callback 163 | 164 | Returns: 165 | None 166 | """ 167 | 168 | result = Adw.AlertDialog.choose_finish(source, result) 169 | if result == 'cancel': 170 | return 171 | 172 | parent_dialog = self.get_ancestor(dialog.AddManualDialog) 173 | old_season = parent_dialog.get_season( 174 | self.season_title, self.poster_uri, self.episodes) 175 | parent_dialog.seasons.remove(old_season) 176 | parent_dialog.update_seasons_ui() 177 | -------------------------------------------------------------------------------- /src/ui/views/main_view.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | using Gtk 4.0; 6 | using Adw 1; 7 | 8 | template $MainView: Adw.Bin { 9 | 10 | map => $_on_map(); 11 | 12 | ShortcutController { 13 | scope: global; 14 | 15 | Shortcut { 16 | trigger: "n"; 17 | action: "action(win.add-tmdb)"; 18 | } 19 | 20 | Shortcut { 21 | trigger: "n"; 22 | action: "action(win.add-manual)"; 23 | } 24 | 25 | Shortcut { 26 | trigger: "F5"; 27 | action: "action(win.refresh)"; 28 | } 29 | 30 | Shortcut { 31 | trigger: "r"; 32 | action: "action(win.refresh)"; 33 | } 34 | 35 | Shortcut { 36 | trigger: "f"; 37 | action: "action(win.search)"; 38 | } 39 | } 40 | 41 | Adw.NavigationView { 42 | Adw.NavigationPage { 43 | title: "Ticket Booth"; 44 | child: Adw.BreakpointBin { 45 | width-request: 360; 46 | height-request: 600; 47 | 48 | Adw.Breakpoint { 49 | condition("max-width: 550sp") 50 | setters { 51 | _header_bar.title-widget: null; 52 | _switcher_bar.reveal: true; 53 | _search_entry.width-request: -1; 54 | } 55 | } 56 | 57 | child: Adw.ToolbarView { 58 | 59 | [top] 60 | Adw.HeaderBar _header_bar { 61 | title-widget: Adw.InlineViewSwitcher _title { 62 | stack: _tab_stack; 63 | display-mode: both; 64 | styles ["flat"] 65 | }; 66 | 67 | [start] 68 | MenuButton _add_btn { 69 | icon-name: "plus"; 70 | tooltip-text: _("Add a title to your library"); 71 | menu-model: _add_menu; 72 | } 73 | 74 | [start] 75 | ToggleButton _show_search_btn { 76 | icon-name: "loupe"; 77 | tooltip-text: _("Show/Hide search bar"); 78 | toggled => $_on_search_btn_toggled(); 79 | } 80 | 81 | [end] 82 | MenuButton _menu_btn { 83 | icon-name: "open-menu"; 84 | menu-model: _primary_menu; 85 | tooltip-text: _("Main Menu"); 86 | } 87 | 88 | [end] 89 | $BackgroundIndicator _background_indicator { 90 | visible: true; 91 | } 92 | } 93 | 94 | [top] 95 | SearchBar _search_bar { 96 | search-mode-enabled: bind _show_search_btn.active; 97 | styles ["inline"] 98 | 99 | child: Box { 100 | orientation: horizontal; 101 | spacing: 6; 102 | 103 | DropDown _search_mode { 104 | model: StringList { 105 | strings [ 106 | C_("Search mode", "Title"), 107 | C_("Search mode", "Genre"), 108 | C_("Search mode", "Overview"), 109 | C_("Search mode", "Notes"), 110 | C_("Search mode", "TMDB ID") 111 | ] 112 | }; 113 | selected: 0; 114 | } 115 | 116 | SearchEntry _search_entry { 117 | halign: center; 118 | width-request: 430; 119 | activates-default: true; 120 | placeholder-text: _("Search Your Watchlist"); 121 | search-delay: 500; 122 | search-changed => $_on_searchentry_search_changed(); 123 | } 124 | }; 125 | } 126 | 127 | content: Box { 128 | orientation: vertical; 129 | 130 | Adw.Banner _banner { 131 | title: _("Offline Mode Enabled"); 132 | button-label: _("Preferences"); 133 | action-name: "app.preferences"; 134 | } 135 | 136 | Adw.ViewStack _tab_stack { 137 | vexpand: true; 138 | hexpand: true; 139 | } 140 | }; 141 | 142 | [bottom] 143 | Adw.ViewSwitcherBar _switcher_bar { 144 | stack: _tab_stack; 145 | } 146 | }; 147 | }; 148 | } 149 | } 150 | } 151 | 152 | menu _primary_menu { 153 | 154 | section { 155 | item { 156 | custom: "themeswitcher"; 157 | } 158 | } 159 | 160 | section { 161 | submenu { 162 | label: _("_Sorting"); 163 | 164 | section { 165 | item { 166 | label: _("A-Z"); 167 | action: "win.view-sorting"; 168 | target: "az"; 169 | } 170 | 171 | item { 172 | label: _("Z-A"); 173 | action: "win.view-sorting"; 174 | target: "za"; 175 | } 176 | 177 | item { 178 | label: _("Date added (newest first)"); 179 | action: "win.view-sorting"; 180 | target: "added-date-new"; 181 | } 182 | 183 | item { 184 | label: _("Date added (oldest first)"); 185 | action: "win.view-sorting"; 186 | target: "added-date-old"; 187 | } 188 | 189 | item { 190 | label: _("Release date (newest first)"); 191 | action: "win.view-sorting"; 192 | target: "released-date-new"; 193 | } 194 | 195 | item { 196 | label: _("Release date (oldest first)"); 197 | action: "win.view-sorting"; 198 | target: "released-date-old"; 199 | } 200 | } 201 | 202 | section { 203 | item { 204 | label: _("Separate watched from unwatched"); 205 | action: 'win.separate-watched'; 206 | } 207 | 208 | item { 209 | label: _("Hide watched"); 210 | action: 'win.hide-watched'; 211 | } 212 | } 213 | } 214 | 215 | item { 216 | label: _("_Refresh"); 217 | action: "win.refresh"; 218 | } 219 | } 220 | 221 | section { 222 | item { 223 | label: _("_Import"); 224 | action: "app.import"; 225 | } 226 | 227 | item { 228 | label: _("_Export"); 229 | action: "app.export"; 230 | } 231 | } 232 | 233 | section { 234 | item { 235 | label: _("_Preferences"); 236 | action: "app.preferences"; 237 | } 238 | 239 | item { 240 | label: _("_Keyboard Shortcuts"); 241 | action: "win.show-help-overlay"; 242 | } 243 | 244 | item { 245 | label: _("_About Ticket Booth"); 246 | action: "app.about"; 247 | } 248 | } 249 | } 250 | 251 | menu _add_menu { 252 | item { 253 | label: _("From The Movie Database (TMDB)"); 254 | action: "win.add-tmdb"; 255 | } 256 | 257 | item { 258 | label: _("Manually"); 259 | action: "win.add-manual"; 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /src/ui/dialogs/add_manual.blp: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2022 - 2023 Alessandro Iepure 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | using Gtk 4.0; 5 | using Adw 1; 6 | 7 | template $AddManualDialog: Adw.Dialog { 8 | content-height: 670; 9 | content-width: 650; 10 | map => $_on_map(); 11 | 12 | Adw.ToolbarView { 13 | [top] 14 | Adw.HeaderBar { 15 | show-end-title-buttons: false; 16 | show-start-title-buttons: false; 17 | 18 | title-widget: Box { 19 | orientation: vertical; 20 | 21 | Adw.WindowTitle { 22 | valign: center; 23 | vexpand: true; 24 | visible: bind template.edit-mode; 25 | title: bind template.title; 26 | } 27 | 28 | Box { 29 | visible: bind template.edit-mode inverted; 30 | orientation: horizontal; 31 | valign: center; 32 | 33 | styles [ 34 | "linked" 35 | ] 36 | 37 | Adw.ToggleGroup _toggle_group { 38 | notify::active => $_on_toggle_group_changed(); 39 | 40 | Adw.Toggle _movies_btn { 41 | name: "movies"; 42 | label: C_("Category", "Movie"); 43 | } 44 | 45 | Adw.Toggle _series_btn { 46 | name: "series"; 47 | label: C_("Category", "TV Series"); 48 | } 49 | } 50 | } 51 | }; 52 | 53 | [start] 54 | Button { 55 | label: _("Cancel"); 56 | action-name: 'window.close'; 57 | } 58 | 59 | [end] 60 | Button _save_btn { 61 | label: _("Save"); 62 | sensitive: false; 63 | clicked => $_on_done_btn_clicked(); 64 | 65 | styles [ 66 | "suggested-action" 67 | ] 68 | } 69 | } 70 | 71 | content: ScrolledWindow { 72 | vexpand: true; 73 | hexpand: true; 74 | 75 | Box { 76 | orientation: vertical; 77 | margin-start: 20; 78 | margin-end: 20; 79 | margin-bottom: 20; 80 | margin-top: 20; 81 | 82 | $ImageSelector _poster {} 83 | 84 | Adw.PreferencesGroup { 85 | margin-bottom: 20; 86 | title: _("General"); 87 | 88 | Adw.EntryRow _title_entry { 89 | title: _("Title (required)"); 90 | changed => $_on_title_changed(); 91 | } 92 | 93 | Adw.ActionRow { 94 | title: _("Release Date"); 95 | activatable-widget: _release_date_menu_btn; 96 | 97 | MenuButton _release_date_menu_btn { 98 | valign: center; 99 | 100 | popover: Popover { 101 | Calendar _calendar { 102 | day-selected => $_on_calendar_day_selected(); 103 | } 104 | }; 105 | 106 | styles [ 107 | "flat" 108 | ] 109 | } 110 | } 111 | 112 | Adw.EntryRow _genres_entry { 113 | title: _("Genres (comma separated)"); 114 | } 115 | 116 | Adw.SpinRow _runtime_spinrow { 117 | // visible: bind _movies_btn.active; 118 | visible: bind $_show_for_movies(_toggle_group.active-name) as ; 119 | title: _("Runtime (minutes)"); 120 | 121 | adjustment: Adjustment { 122 | lower: 0; 123 | upper: 900; 124 | step-increment: 1; 125 | }; 126 | } 127 | 128 | Adw.EntryRow _tagline_entry { 129 | title: _("Tagline"); 130 | } 131 | 132 | Adw.EntryRow _creator_entry { 133 | // visible: bind _series_btn.active; 134 | visible: bind $_show_for_series(_toggle_group.active-name) as ; 135 | title: _("Created by"); 136 | } 137 | } 138 | 139 | Adw.PreferencesGroup { 140 | title: _("Overview"); 141 | margin-bottom: 20; 142 | 143 | Gtk.TextView _overview_text { 144 | height-request: 100; 145 | top-margin: 12; 146 | bottom-margin: 12; 147 | right-margin: 12; 148 | left-margin: 12; 149 | wrap-mode: word; 150 | 151 | styles [ 152 | "card" 153 | ] 154 | } 155 | } 156 | 157 | Adw.PreferencesGroup _seasons_group { 158 | // visible: bind _series_btn.active; 159 | visible: bind $_show_for_series(_toggle_group.active-name) as ; 160 | title: _("Seasons (required)"); 161 | description: _("Use the + button to add seasons"); 162 | 163 | [header-suffix] 164 | Button { 165 | Adw.ButtonContent { 166 | label: _("Add"); 167 | icon-name: "plus"; 168 | } 169 | 170 | clicked => $_on_season_add_btn_clicked(); 171 | 172 | styles [ 173 | "accent" 174 | ] 175 | } 176 | } 177 | 178 | Adw.PreferencesGroup { 179 | title: _("Additional Information"); 180 | 181 | Adw.EntryRow _status_entry { 182 | title: _("Status"); 183 | } 184 | 185 | Adw.ComboRow _original_language_comborow { 186 | title: _("Original Language"); 187 | 188 | model: StringList _language_model {}; 189 | } 190 | 191 | Adw.EntryRow _original_title_entry { 192 | title: _("Original Title"); 193 | } 194 | 195 | Adw.SpinRow _budget_spinrow { 196 | // visible: bind _movies_btn.active; 197 | visible: bind $_show_for_movies(_toggle_group.active-name) as ; 198 | title: _("Budget (US$)"); 199 | 200 | adjustment: Adjustment { 201 | lower: 0; 202 | upper: 9999999999999999999; 203 | step-increment: 1; 204 | }; 205 | } 206 | 207 | Adw.SpinRow _revenue_spinrow { 208 | // visible: bind _movies_btn.active; 209 | visible: bind $_show_for_movies(_toggle_group.active-name) as ; 210 | title: _("Revenue (US$)"); 211 | 212 | adjustment: Adjustment { 213 | lower: 0; 214 | upper: 9999999999999999999; 215 | step-increment: 1; 216 | }; 217 | } 218 | 219 | Adw.ActionRow { 220 | title: _("In production"); 221 | // visible: bind _series_btn.active; 222 | visible: bind $_show_for_series(_toggle_group.active-name) as ; 223 | activatable-widget: _production_checkbtn; 224 | 225 | [suffix] 226 | CheckButton _production_checkbtn { 227 | valign: center; 228 | } 229 | } 230 | } 231 | } 232 | }; 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/views/db_update_view.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Alessandro Iepure 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import locale 6 | import logging 7 | import os 8 | import sqlite3 9 | import time 10 | from datetime import datetime, timedelta 11 | from gettext import gettext as _ 12 | 13 | from gi.repository import Adw, Gio, GLib, GObject, Gtk 14 | 15 | from .. import shared # type: ignore 16 | from ..models.movie_model import MovieModel 17 | from ..models.series_model import SeriesModel 18 | from ..providers.local_provider import LocalProvider as local 19 | from ..providers.tmdb_provider import TMDBProvider as tmdb 20 | 21 | @Gtk.Template(resource_path=shared.PREFIX + '/ui/views/db_update_view.ui') 22 | class DbUpdateView(Adw.Bin): 23 | """ 24 | This class represents the initial setup the app needs to offer full fuctionality. 25 | 26 | Properties: 27 | None 28 | 29 | Methods: 30 | None 31 | 32 | Signals: 33 | exit: emited when the view has completed its operations, either the required data was successfully downloaded \ 34 | or the user requested "offline mode" 35 | """ 36 | 37 | __gtype_name__ = 'DbUpdateView' 38 | 39 | _progress_bar = Gtk.Template.Child() 40 | _update_progress_page = Gtk.Template.Child() 41 | _notification_question = Gtk.Template.Child() 42 | _activate_btn = Gtk.Template.Child() 43 | _deactivate_btn = Gtk.Template.Child() 44 | _carousel = Gtk.Template.Child() 45 | _offline_btn = Gtk.Template.Child() 46 | _retry_check_btn = Gtk.Template.Child() 47 | 48 | __gsignals__ = { 49 | 'exit': (GObject.SIGNAL_RUN_FIRST, None, ()), 50 | } 51 | 52 | _cancellable = Gio.Cancellable.new() 53 | 54 | def __init__(self): 55 | super().__init__() 56 | 57 | @Gtk.Template.Callback('_on_map') 58 | def _on_map(self, user_data: object | None) -> None: 59 | """ 60 | Callback for "map" signal. 61 | Creates the directories and tables in the local db, sets the tmdb results language based on the locale, and attempts to download required data if connected to the Internet. 62 | 63 | Args: 64 | user_data (object or None): user data passed to the callback. 65 | 66 | Returns: 67 | None 68 | """ 69 | 70 | 71 | logging.info('Updating Database...') 72 | 73 | local.update_movies_table() 74 | local.update_series_table() 75 | 76 | logging.info('Added new Columns to Database') 77 | 78 | if shared.schema.get_boolean('offline-mode'): 79 | self.emit('exit') 80 | logging.info('Refetching Data from TMDB...') 81 | self.task = Gio.Task.new() 82 | self.task.run_in_thread( 83 | lambda*_:self._fetch_data_from_tmdb() 84 | ) 85 | 86 | 87 | @Gtk.Template.Callback('_on_offline_btn_clicked') 88 | def _on_offline_btn_clicked(self, user_data: object | None) -> None: 89 | """ 90 | Callback for "clicked" signal. 91 | Stops the background network check and sets the app in offline mode. An option to retry on next launch is 92 | provided. 93 | 94 | Args: 95 | user_data (object or None): user data passed to the callback. 96 | 97 | Returns: 98 | None 99 | """ 100 | 101 | self._cancellable.cancel() 102 | 103 | shared.schema.set_boolean('offline-mode', True) 104 | logging.info('[Update] Offline mode enabled') 105 | if not self._retry_check_btn.get_active(): 106 | shared.schema.set_boolean('db-needs-update', False) 107 | logging.info('[Update] Database update partially complete') 108 | 109 | logging.info('[Update] Database update not completed, retrying on next run') 110 | self.emit('exit') 111 | 112 | def _fetch_data_from_tmdb(self) : 113 | with sqlite3.connect(shared.db) as connection: 114 | 115 | movies = local.get_all_movies() 116 | series = local.get_all_series() 117 | 118 | total = len(movies) + len(series) 119 | counter = 0 120 | for movie in movies: 121 | if not movie.manual: 122 | new_movie = MovieModel(tmdb.get_movie(movie.id)) 123 | #Check if the soon_release flag should be set, that is the case if the movie is set to release in less than 14 days 124 | if len(movie.release_date) > 0 and datetime.strptime(movie.release_date, '%Y-%m-%d') < datetime.now() + timedelta(days=14): # TODO make this a variable and sync with main_view.py 125 | #Writing to the local db since update_movie updates entry of the local db 126 | local.set_soon_release_status(movie.id, True, True) 127 | local.update_movie(movie, new_movie) 128 | counter += 1 129 | self._progress_bar.set_fraction(counter/total) 130 | 131 | for serie in series: 132 | if not serie.manual: 133 | new_serie = SeriesModel(tmdb.get_serie(serie.id)) 134 | #Check if the soon_release flag should be set, that is the case if the next episode is less than 7 days away 135 | compare_date = new_serie.next_air_date 136 | if len(compare_date) > 0 and datetime.strptime(compare_date, '%Y-%m-%d') < datetime.now() + timedelta(days=7): # TODO make this a variable and sync with main_view.py 137 | #Writing to variable since update_series deletes local db entry and creates a new entry with flags from argument 138 | serie.soon_release = True 139 | local.update_series(serie, new_serie) 140 | counter += 1 141 | self._progress_bar.set_fraction(counter/total) 142 | 143 | logging.info('Fetched Data from TMDB.') 144 | 145 | #Go to next page in the carousel 146 | index = int(self._carousel.get_position()) 147 | next_page = self._carousel.get_nth_page(index + 1) 148 | self._carousel.scroll_to(next_page, True) 149 | 150 | @Gtk.Template.Callback('_on_deactivate_btn_clicked') 151 | def _on_btn_deactivate_clicked(self, user_data: object | None) -> None: 152 | """ 153 | Callback for "clicked" signal. 154 | Sets all activate_notification to false in local db and exits carousel 155 | 156 | Args: 157 | user_data (object or None): user data passed to the callback. 158 | 159 | Returns: 160 | None 161 | """ 162 | local.reset_activate_notification() 163 | shared.schema.set_boolean('db-needs-update', False) 164 | logging.debug(f'db update dialog: confim, deactivate notifications') 165 | self.emit('exit') 166 | 167 | @Gtk.Template.Callback('_on_activate_btn_clicked') 168 | def _on_btn_activate_clicked(self, user_data: object | None) -> None: 169 | """ 170 | Callback for "clicked" signal. 171 | Exits carousel. 172 | 173 | Args: 174 | user_data (object or None): user data passed to the callback. 175 | 176 | Returns: 177 | None 178 | """ 179 | shared.schema.set_boolean('db-needs-update', False) 180 | self.emit('exit') -------------------------------------------------------------------------------- /LICENSES/CC0-1.0.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | --------------------------------------------------------------------------------