2 ### -*- mode: python; coding: utf-8 -*-
4 from contextlib
import contextmanager
9 class ExpectedError (Exception): pass
18 def filter(value
, func
= None, dflt
= None):
19 if value
is None: return dflt
20 elif func
is None: return value
21 else: return func(value
)
24 if not cond
: raise ExpectedError(msg
)
27 if not s
.isdigit(): raise ExpectedError("bad integer `%s'" % s
)
33 me
._i
, me
._n
= 0, len(s
)
35 s
, i
, n
= me
._s
, me
._i
, me
._n
37 if not s
[i
].isspace(): return i
42 begin
= i
= me
._wordstart()
43 if begin
< 0: return None
44 while i
< n
and not s
[i
].isspace(): i
+= 1
49 begin
= me
._wordstart()
50 if begin
< 0: return None
51 else: return s
[begin
:].rstrip()
53 URL_SAFE_P
= 256*[False]
55 b
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" \
56 b
"abcdefghijklmnopqrstuvwxyz" \
57 b
"0123456789" b
"!$%-.,/":
60 return "".join((URL_SAFE_P
[ch
] and chr(ch
) or "%%%02x" % ch
61 for ch
in s
.encode("UTF-8")))
63 PROG
= OS
.path
.basename(SYS
.argv
[0])
65 class BaseLocation (object):
67 SYS
.stderr
.write("%s: %s%s\n" %
(PROG
, me
._loc(), exc
))
69 class DummyLocation (BaseLocation
):
70 def _loc(me
): return ""
72 class FileLocation (BaseLocation
):
73 def __init__(me
, fn
, lno
= 1): me
._fn
, me
._lno
= fn
, lno
74 def _loc(me
): return "%s:%d: " %
(me
._fn
, me
._lno
)
75 def stepline(me
): me
._lno
+= 1
79 class Source (object):
81 TITLEP
= CHAPTERP
= False
85 me
.used_titles
= dict()
86 me
.used_chapters
= set()
88 def url(me
, title
= None, chapter
= None):
90 if me
.TITLEP
: raise ExpectedError("missing title number")
91 if chapter
is not None:
92 raise ExpectedError("can't specify chapter without title")
95 raise ExpectedError("can't specify title with `%s'" % me
.fn
)
97 suffix
= "#%d" % title
99 raise ExpectedError("can't specify chapter with `%s'" % me
.fn
)
101 suffix
= "#%d:%d-%d:%d" %
(title
, chapter
, title
, chapter
)
102 if chapter
is not None: key
, set = (title
, chapter
), me
.used_chapters
103 else: key
, set = title
, me
.used_titles
106 raise ExpectedError("`%s' already used" % me
.fn
)
107 elif chapter
is None:
108 raise ExpectedError("`%s' title %d already used" %
(me
.fn
, title
))
110 raise ExpectedError("`%s' title %d chapter %d already used" %
111 (me
.fn
, title
, chapter
))
112 if chapter
is not None: me
.used_chapters
.add((title
, chapter
))
113 return me
.PREFIX
+ ROOT
+ urlencode(me
.fn
) + suffix
115 class VideoDisc (Source
):
117 TITLEP
= CHAPTERP
= True
119 def __init__(me
, fn
, *args
, **kw
):
120 super().__init__(fn
, *args
, **kw
)
123 class VideoSeason (object):
124 def __init__(me
, i
, title
):
128 def set_episode_disc(me
, i
, disc
):
130 raise ExpectedError("season %d episode %d already taken" %
(me
.i
, i
))
131 me
.episodes
[i
] = disc
; disc
.neps
+= 1
133 def some_group(m
, *gg
):
136 if s
is not None: return s
139 class VideoDir (object):
141 _R_ISO_PRE
= RX
.compile(r
"""
143 (?: S (?P<si> \d+) (?: \. \ (?P<st> .*)—)? (?: D (?P<sdi> \d+))? |
150 _R_ISO_EP
= RX
.compile(r
"""
151 ^ E (?P<ei> \d+) (?: – (?P<ej> \d+))? $
154 def __init__(me
, dir):
156 fns
= OS
.listdir(OS
.path
.join(ROOT
, dir))
158 season
, last_j
= None, 0
161 path
= OS
.path
.join(dir, fn
)
162 if not fn
.endswith(".iso"): continue
163 m
= me
._R_ISO_PRE
.match(fn
)
166 i
= filter(m
.group("si"), int, 1)
167 stitle
= m
.group("st")
168 if season
is None or i
!= season
.i
:
169 check(season
is None or i
== season
.i
+ 1,
170 "season %d /= %d" %
(i
, season
is None and -1 or season
.i
+ 1))
171 check(i
not in seasons
, "season %d already seen" % i
)
172 seasons
[i
] = season
= VideoSeason(i
, stitle
)
175 check(stitle
== season
.title
,
176 "season title `%s' /= `%s'" %
(stitle
, season
.title
))
177 j
= filter(some_group(m
, "sdi", "di"), int)
179 check(j
== last_j
+ 1,
180 "season %d disc %d /= %d" %
(season
.i
, j
, last_j
+ 1))
182 disc
= VideoDisc(path
)
183 any
, bad
= False, False
184 for eprange
in m
.group("eps").split(", "):
185 mm
= me
._R_ISO_EP
.match(eprange
)
186 if mm
is None: bad
= True; continue
187 start
= filter(mm
.group("ei"), int)
188 end
= filter(mm
.group("ej"), int, start
)
189 for k
in range(start
, end
+ 1):
190 season
.set_episode_disc(k
, disc
)
193 raise ExpectedError("bad ep list in `%s'", fn
)
197 class AudioDisc (Source
):
199 TITLEP
= CHAPTERP
= False
201 class AudioEpisode (Source
):
203 TITLEP
= CHAPTERP
= False
204 def __init__(me
, fn
, i
, *args
, **kw
):
205 super().__init__(fn
, *args
, **kw
)
208 class AudioDir (object):
210 _R_FLAC
= RX
.compile(r
"""
217 def __init__(me
, dir):
219 fns
= OS
.listdir(OS
.path
.join(ROOT
, dir))
224 path
= OS
.path
.join(dir, fn
)
225 if not fn
.endswith(".flac"): continue
226 m
= me
._R_FLAC
.match(fn
)
228 i
= filter(m
.group(1), int)
230 check(i
== last_i
+ 1, "episode %d /= %d" %
(i
, last_i
+ 1))
231 episodes
[i
] = AudioEpisode(path
, i
)
233 me
.episodes
= episodes
235 class Chapter (object):
236 def __init__(me
, episode
, title
, i
):
237 me
.title
, me
.i
= title
, i
238 me
.url
= episode
.source
.url(episode
.tno
, i
)
240 class Episode (object):
241 def __init__(me
, season
, i
, neps
, title
, src
, tno
= None):
243 me
.i
, me
.neps
, me
.title
= i
, neps
, title
245 me
.source
, me
.tno
= src
, tno
246 me
.url
= src
.url(tno
)
247 def add_chapter(me
, title
, j
):
248 ch
= Chapter(me
, title
, j
)
249 me
.chapters
.append(ch
)
252 return me
.season
._eplabel(me
.i
, me
.neps
, me
.title
)
254 class Season (object):
255 def __init__(me
, playlist
, title
, i
, implicitp
= False):
256 me
.playlist
= playlist
257 me
.title
, me
.i
= title
, i
258 me
.implicitp
= implicitp
260 def add_episode(me
, j
, neps
, title
, src
, tno
):
261 ep
= Episode(me
, j
, neps
, title
, src
, tno
)
262 me
.episodes
.append(ep
)
264 def _eplabel(me
, i
, neps
, title
):
265 if neps
== 1: epname
= me
.playlist
.epname
; epn
= "%d" % i
266 elif neps
== 2: epname
= me
.playlist
.epnames
; epn
= "%d, %d" %
(i
, i
+ 1)
267 else: epname
= me
.playlist
.epnames
; epn
= "%d–%d" %
(i
, i
+ neps
- 1)
269 if me
.implicitp
: return "%s %s" %
(epname
, epn
)
270 elif me
.title
is None: return "%s %d.%s" %
(epname
, me
.i
, epn
)
271 else: return "%s—%s %s" %
(me
.title
, epname
, epn
)
273 if me
.implicitp
: return "%s. %s" %
(epn
, title
)
274 elif me
.title
is None: return "%d.%s. %s" %
(me
.i
, epn
, title
)
275 else: return "%s—%s. %s" %
(me
.title
, epn
, title
)
277 class MovieSeason (object):
278 def __init__(me
, playlist
):
279 me
.playlist
= playlist
283 def add_episode(me
, j
, neps
, title
, src
, tno
):
284 if title
is None: raise ExpectedError("movie must have a title")
285 ep
= Episode(me
, j
, neps
, title
, src
, tno
)
286 me
.episodes
.append(ep
)
288 def _eplabel(me
, i
, epn
, title
):
291 class Playlist (object):
295 me
.epname
, me
.epnames
= "Episode", "Episodes"
297 def add_season(me
, title
, i
, implicitp
= False):
298 season
= Season(me
, title
, i
, implicitp
)
299 me
.seasons
.append(season
)
303 season
= MovieSeason(me
)
304 me
.seasons
.append(season
)
309 for season
in me
.seasons
:
311 for i
, ep
in enumerate(season
.episodes
, 1):
313 f
.write("#EXTINF:0,,%s\n%s\n" %
(ep
.label(), ep
.url
))
315 for ch
in ep
.chapters
:
316 f
.write("#EXTINF:0,,%s: %s\n%s\n" %
317 (ep
.label(), ch
.title
, ch
.url
))
322 playlist
= Playlist()
323 season
, episode
, chapter
, ep_i
= None, None, None, 1
326 with
location(FileLocation(fn
, 0)) as floc
:
327 with
open(fn
, "r") as f
:
330 sline
= line
.lstrip()
331 if sline
== "" or sline
.startswith(";"): continue
333 if line
.startswith("!"):
336 check(cmd
is not None, "missing command")
340 check(v
is not None, "missing season number")
342 check(v
.rest() is None, "trailing junk")
343 season
= playlist
.add_movies()
347 season
= playlist
.add_season(title
, i
, implicitp
= False)
348 episode
= chapter
= None
352 check(ww
.rest() is None, "trailing junk")
353 season
= playlist
.add_movies()
354 episode
= chapter
= None
357 elif cmd
== "epname":
359 check(name
is not None, "missing episode name")
360 try: sep
= name
.index(":")
361 except ValueError: names
= name
+ "s"
362 else: name
, names
= name
[:sep
], name
[sep
+ 1:]
363 playlist
.epname
, playlist
.epnames
= name
, names
367 check(i
is not None, "missing episode number")
371 fn
= ww
.rest(); check(fn
is not None, "missing filename")
372 if fn
== "-": iso
= None
374 check(OS
.path
.exists(OS
.path
.join(ROOT
, fn
)),
375 "iso file `%s' not found" % fn
)
379 name
= ww
.nextword(); check(name
is not None, "missing name")
380 fn
= ww
.rest(); check(fn
is not None, "missing directory")
383 except KeyError: pass
385 vds
[name
] = VideoDir(fn
)
388 fn
= ww
.rest(); check(fn
is not None, "missing directory")
389 if fn
== "-": ads
= None
390 else: ads
= AudioDir(fn
)
396 raise ExpectedError("unknown command `%s'" % cmd
)
400 if not line
[0].isspace():
404 check(conf
is not None, "missing config")
405 i
, vdname
, neps
, fake_epi
= UNSET
, "-", 1, ep_i
406 for c
in conf
.split(","):
407 if c
.isdigit(): i
= int(c
)
408 elif c
== "-": i
= None
410 eq
= c
.find("="); check(eq
>= 0, "bad assignment `%s'" % c
)
411 k
, v
= c
[:eq
], c
[eq
+ 1:]
412 if k
== "vd": vdname
= v
413 elif k
== "n": neps
= getint(v
)
414 elif k
== "ep": fake_epi
= getint(v
)
415 else: raise ExpectedError("unknown setting `%s'" % k
)
418 check(i
is not UNSET
, "no title number")
420 season
= playlist
.add_season(None, 1, implicitp
= True)
423 check(ads
, "no title, but no audio directory")
424 check(season
.implicitp
, "audio source, but explicit season")
425 try: src
= ads
.episodes
[ep_i
]
427 raise ExpectedError("episode %d not found in audio dir `%s'" %
434 check(vdname
in vds
, "title, but no iso or video directory")
435 try: vdir
= vds
[vdname
]
437 raise ExpectedError("video dir label `%s' not set" % vdname
)
438 try: s
= vdir
.seasons
[season
.i
]
440 raise ExpectedError("season %d not found in video dir `%s'" %
441 (season
.i
, vdir
.dir))
442 try: src
= s
.episodes
[ep_i
]
444 raise ExpectedError("episode %d.%d not found in video dir `%s'" %
445 (season
.i
, ep_i
, vdir
.dir))
447 episode
= season
.add_episode(fake_epi
, neps
, title
, src
, i
)
449 ep_i
+= neps
; src
.nuses
+= neps
454 check(episode
is not None, "no current episode")
455 check(episode
.source
.CHAPTERP
,
456 "episode source doesn't allow chapters")
457 if chapter
is None: j
= 1
459 chapter
= episode
.add_chapter(title
, j
)
462 for vdir
in vds
.values():
463 for s
in vdir
.seasons
.values():
464 for d
in s
.episodes
.values():
466 for d
in sorted(discs
, key
= lambda d
: d
.fn
):
467 if d
.neps
!= d
.nuses
:
468 raise ExpectedError("disc `%s' has %d episodes, used %d times" %
469 (d
.fn
, d
.neps
, d
.nuses
))
473 ROOT
= "/mnt/dvd/archive/"
476 for f
in SYS
.argv
[1:]:
477 parse_list(f
).write(SYS
.stdout
)
478 except (ExpectedError
, IOError, OSError) as e
: